diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f439a17c86..fc8900ddf8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -39,3 +39,41 @@ updates: interval: weekly day: monday open-pull-requests-limit: 5 + + # The Android companion's Gradle build (audit A69), weekly and grouped like npm: two PRs carry the + # routine minor/patch bumps (the protocol's network and crypto stack, then everything else; see + # `groups`), a major (AGP, Kotlin, okhttp, Gradle itself) comes on its own. /android covers + # the whole build: the app (include(":app")), the protocol module, which is an included build + # (includeBuild("protocol") in android/settings.gradle.kts; Dependabot follows includeBuild, so it + # needs no entry of its own, and a second entry for /android/protocol would open each of its bumps + # twice), and the Gradle wrapper in android/gradle/wrapper. Not android/tools/typecheck: that offline + # type-check is not run by CI, so a PR against it would be untested, and its pins mirror the app's + # (Kotlin, BouncyCastle) or stand in for them (Compose Multiplatform desktop for Jetpack Compose, + # robolectric's android-all for compileSdk 35), so they move by hand with the app's, never to + # whatever is newest. GradleCiCoverageTest (android/protocol) pins this. + - package-ecosystem: gradle + directory: /android + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + # The protocol's network and crypto stack (the relay socket, the direct-SSH transport, the phone's + # Ed25519 identity and the BouncyCastle provider sshj needs on Android) gets a PR of its own. In the + # catch-all below it would only ever move together with every androidx/Compose/AGP/Kotlin bump, and + # that PR stays red whenever one of them needs something the build lacks: androidx releases raise + # the compileSdk their AAR metadata demands, which :app:checkDebugAarMetadata enforces against the + # app's compileSdk. Nothing of this group would land until someone fixed that by hand. Dependabot + # gives a dependency matching two groups to the more specific one (these patterns beat "*"), and + # within a run a group listed earlier handles it first, so keep this group above the catch-all. + # GradleCiCoverageTest (android/protocol) pins that no other dependency shares its PR. + protocol-net-crypto: + patterns: + - "com.squareup.okhttp3:*" + - "com.hierynomus:*" + - "net.i2p.crypto:*" + - "org.bouncycastle:*" + update-types: [minor, patch] + android-minor-patch: + patterns: ["*"] + update-types: [minor, patch] diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000000..1142bcd740 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,440 @@ +name: Android + +# The Android companion (android/): the pure-Kotlin protocol module, tested against this repo's own +# desktop host code, and the Compose app built against the Android SDK the runner ships. +# +# The path filters below (the same list for push and pull_request) are the inputs of what this workflow +# runs, derived from the interop fixture's esbuild metafile rather than guessed (audit A63). The first +# filter listed only android/**, src/main/remote/** and src/main/pairing-*.ts, which missed 35 of the 49 +# desktop sources bundled into the fixture on 2026-09-26 (all 13 from src/core, all 21 from src/shared, +# and src/main/windows-ssh-keys.ts), so a change to one ran no Android test. Each entry and why: +# android/** the Kotlin modules, the fixture and its bundler, the app sources tests read +# src/core/** bundled (pending-approvals, project-kanban-write, agent-status-mirror, and +# since A64 projects-list-blob + workspace-store, which build the projects.list +# blob, ...); also ack-sweep behind ~/.nodeterm/acks, which is not bundled +# src/session-host/** imported by PtyManager in the managed-session producer fixture (A112) +# src/shared/** bundled (types, agents/*, kanban-labels, ssh, ...); also pair-qr.ts (the +# pairing QR envelope) and agents/pane.ts (read by ResumeOfferTest) +# src/main/*.ts bundled: pairing-core, pairing-service, windows-ssh-keys, ssh-host-keys; also index.ts, which +# names the projects.list blob's sources and wires the relay verbs +# src/main/remote/** bundled: host-service, connectHostSession and the relay/E2EE code under it +# tsconfig.json esbuild applies its compilerOptions to every bundled file (the root one; it +# does not follow the references to tsconfig.node.json, so that file is absent: +# the fixture's type check runs in ci.yml, which has no path filter) +# package.json, package-lock.json `npm ci` installs esbuild, ws and tweetnacl from them +# .github/workflows/android.yml this file +# .github/dependabot.yml GradleCiCoverageTest (android/protocol) reads it: the Gradle Dependabot +# entry (audit A69); the Kotlin CodeQL job it pins is in this file +# docs/android.md, docs/android-audit-2026-09.md DeviceChecklistDocsTest reads them: the device +# checklist and the audit ids its items name (audit A65) +# CONTRIBUTING.md ContributorDocsTest reads it (with docs/android.md and the two READMEs under +# android/): the JDK range and the `npm ci --ignore-scripts` warning (A61, A71) +# src/renderer/components/settings/sections/PhoneSection.tsx DeviceChecklistDocsTest reads the +# label of its JSON -> link QR switch, which the camera-app pairing check names +# src/main/remote-ssh/remote-status-push.ts, src/main/remote-ssh/ssh-project.ts, src/server/config.ts +# HostBrowseTest reads them: the status slices' heartbeat and file name, and the +# Server Edition's default data dir, which the direct-SSH browse relies on (A27) +# To re-derive: `node android/protocol/src/test/interop/bundle-fixture.cjs +# android/protocol/src/test/interop/electron-stub.ts` from the repo root writes .meta.json, +# whose `inputs` are the bundled files. WorkflowPathFilterTest (android/protocol) fails when one is not +# matched by both filters. A change that adds an input edits a file that is already one (or the bundler +# or tsconfig.json), so it runs this workflow and that test. The same test fails on a repo file a +# protocol test reads by a literal path (from InteropHarness.repoRoot or a val bound to it) that the +# filters miss, and on a read by a computed path it has not been told about. +on: + push: + branches: [main] + paths: + - 'android/**' + - 'src/core/**' + - 'src/session-host/**' + - 'src/shared/**' + - 'src/main/*.ts' + - 'src/main/remote/**' + - 'tsconfig.json' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/android.yml' + - '.github/dependabot.yml' + - 'docs/android.md' + - 'docs/android-audit-2026-09.md' + - 'CONTRIBUTING.md' + - 'src/renderer/components/settings/sections/PhoneSection.tsx' + - 'src/main/remote-ssh/remote-status-push.ts' + - 'src/main/remote-ssh/ssh-project.ts' + - 'src/server/config.ts' + - 'src/server/platform-server.ts' + - 'src/server/proxy-trust.ts' + - 'src/server/index.ts' + pull_request: + paths: + - 'android/**' + - 'src/core/**' + - 'src/session-host/**' + - 'src/shared/**' + - 'src/main/*.ts' + - 'src/main/remote/**' + - 'tsconfig.json' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/android.yml' + - '.github/dependabot.yml' + - 'docs/android.md' + - 'docs/android-audit-2026-09.md' + - 'CONTRIBUTING.md' + - 'src/renderer/components/settings/sections/PhoneSection.tsx' + - 'src/main/remote-ssh/remote-status-push.ts' + - 'src/main/remote-ssh/ssh-project.ts' + - 'src/server/config.ts' + - 'src/server/platform-server.ts' + - 'src/server/proxy-trust.ts' + - 'src/server/index.ts' + workflow_dispatch: + inputs: + prepare_beta: + description: Build versioned release inputs and run private-beta checks (sign locally) + type: boolean + default: false + beta_version_code: + description: Android version code; increase for each beta update + type: string + default: '2' + beta_version_name: + description: Beta version name (for example 0.1.0-beta.1) + type: string + default: '0.1.0-beta.1' + +# One group per ref for every job here, the Kotlin CodeQL job included. Only pull request runs are +# cancelled in progress: a push run never is, so a push to main (which carries the Kotlin CodeQL +# baseline pull requests are compared with) runs to the end. As anywhere in Actions, a run still pending +# behind it is replaced by a newer one for the same ref, which analyses the newer commit. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +# Use workflow_dispatch with prepare_beta=true to run all five checks on a feature branch. +# Main pushes and pull requests retain ordinary app versions. The older feature-push beta +# defaults remain below, but main-only push admission prevents that historical route. +# No private key enters Actions. +env: + NODETERM_BETA_VERSION_CODE: '2' + NODETERM_BETA_VERSION_NAME: '0.1.0-beta.1' + +# Every job runs gradle/actions/setup-gradle before ./gradlew (audit A69). It checks the committed +# android/gradle/wrapper/gradle-wrapper.jar against Gradle's published wrapper checksums, so a jar that +# is not a released Gradle wrapper fails the job instead of running, and it caches ~/.gradle (the wrapper's Gradle distribution, AGP +# and the dependencies), which every run used to download again. The cache is the MIT-licensed `basic` +# provider (a thin wrapper over actions/cache), not the action's default `enhanced` one: that is the +# closed-source gradle-actions-caching library under the Gradle Technologies Terms of Use, and using it +# accepts those terms for this repository, which is the owner's call (switching is this one input). +# The basic provider uses ONE key for every job, setup-java---gradle-, the CodeQL (Kotlin) job below included, and the first job to save a +# key keeps it. So exactly one job writes it: `app`, which resolves the most (AGP, the app's and +# the protocol module's dependencies, the Gradle distribution), and only on the default branch (the +# action's own default; a pull request never writes). The others read it with cache-read-only and +# download what only they use. GradleCiCoverageTest (android/protocol) pins the validation step and the +# single writer. +jobs: + protocol: + name: Protocol (JVM + desktop interop) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '22' + cache: 'npm' + + # The interop tests run the desktop's relay host / pairing code through esbuild + node. They + # need tweetnacl, ws and esbuild, not a rebuilt node-pty, hence no scripts. They do not need + # Electron either: InteropHarness aliases `electron` to a stub + # (android/protocol/src/test/interop/electron-stub.ts), because the real package's first + # `require` downloads the ~110 MB Electron binary when it is not there yet, as on a fresh + # runner, inside the harness's 20 s ready wait (audit A60). + - name: Install desktop dependencies (no scripts) + run: npm ci --ignore-scripts + + - name: tmux (SSH transport tests run a real, sandboxed tmux) + run: sudo apt-get update -q && sudo apt-get install -y -q tmux + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic + cache-read-only: true + + - name: Protocol tests + working-directory: android + run: ./gradlew -p protocol test --stacktrace + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: protocol-test-report + path: android/protocol/build/reports/tests/test + + app: + name: App (assembleDebug) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + # The one job that writes the shared Gradle cache (see the note above `jobs:`). + - uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic + + - name: Build debug APK + working-directory: android + run: ./gradlew :app:assembleDebug --stacktrace + + - uses: actions/upload-artifact@v4 + with: + name: nodeterm-android-debug + path: android/app/build/outputs/apk/debug/*.apk + + # R8 runs only for the release build type. Building the release here (unsigned; no signing secrets + # exist or are needed) makes a missing -dontwarn in + # app/proguard-rules.pro fail CI instead of the first real release (audit A37): R8 reports the missing + # class. It reports no missing -keep, so tools/check-r8-output.sh then checks that the keeps it names + # matched (the WebView bridge, the worker, BouncyCastle's provider tables, one exception name). A NEW + # reflection target without a keep is not detected: it needs its own keep and a line in that script. + # A separate job so an R8 failure never withholds the debug APK. Green does not prove reflection-loaded + # code works on a phone. + app-release: + name: App release (R8, unsigned) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic + cache-read-only: true + + - name: Build release APK (R8) + working-directory: android + env: + NODETERM_ANDROID_VERSION_CODE: ${{ ((github.event_name == 'workflow_dispatch' && inputs.prepare_beta) || (github.event_name == 'push' && github.ref == 'refs/heads/claude/android-ios-parity-75kfem')) && (inputs.beta_version_code || env.NODETERM_BETA_VERSION_CODE) || '' }} + NODETERM_ANDROID_VERSION_NAME: ${{ ((github.event_name == 'workflow_dispatch' && inputs.prepare_beta) || (github.event_name == 'push' && github.ref == 'refs/heads/claude/android-ios-parity-75kfem')) && (inputs.beta_version_name || env.NODETERM_BETA_VERSION_NAME) || '' }} + run: | + python3 tools/check-beta-version.py + ./gradlew :app:assembleRelease --stacktrace + + - name: Check the R8 keeps matched + working-directory: android + run: sh tools/check-r8-output.sh app/build/outputs/mapping/release + + # Build inputs only: an unsigned APK cannot be installed. The private key and finished APK + # stay on the owner's machine; Actions artifacts on a public repo are not private downloads. + - uses: actions/upload-artifact@v4 + with: + name: nodeterm-android-release-unsigned + path: android/app/build/outputs/apk/release/*-unsigned.apk + if-no-files-found: error + + # On a missing-class failure R8 writes the -dontwarn lines it wanted to missing_rules.txt here. + - uses: actions/upload-artifact@v4 + if: always() + with: + name: r8-release-outputs + path: android/app/build/outputs/mapping/release + if-no-files-found: ignore + + beta-checks: + name: Private beta checks + if: ${{ (github.event_name == 'workflow_dispatch' && inputs.prepare_beta) || (github.event_name == 'push' && github.ref == 'refs/heads/claude/android-ios-parity-75kfem') }} + needs: [protocol, app-release] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '22' + cache: 'npm' + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: tmux for delivery and SSH interop tests + run: sudo apt-get update -q && sudo apt-get install -y -q tmux + + - name: Install desktop dependencies + run: npm ci + + - name: Desktop type check + run: npm run typecheck + + - name: Phone delivery and ack tests + run: >- + npx vitest run + src/core/composed-tmux.test.ts + src/core/composed-tmux.realtmux.test.ts + src/core/pty-composed-input.test.ts + src/core/composed-pty.test.ts + src/core/session-host-composed.test.ts + src/core/native-windows-pane.test.ts + src/session-host/composed-input-host.test.ts + src/main/remote/host-composed-input.test.ts + src/core/history-scroll-view.test.ts + src/core/native-history-scroll.test.ts + src/core/pty-history-scroll.test.ts + src/core/session-host-history-scroll.test.ts + src/session-host/terminal-scroll.test.ts + src/session-host/history-scroll-host.test.ts + src/main/remote/host-history-scroll.test.ts + src/main/remote/android-history-fixture.test.ts + src/core/pty-background-write.test.ts + src/core/pty-background-write-over.test.ts + src/core/pty-shadow.test.ts + src/core/tmux-control-client.test.ts + src/core/tmux-control.test.ts + src/core/remote-send-keys.realtmux.test.ts + src/core/remote-ssh/control-master.test.ts + src/core/ack-sweep.test.ts + src/core/agent-status-mirror.test.ts + src/core/agent-status-mirror.restart.test.ts + src/main/remote-ssh/ssh-project.test.ts + src/main/remote-ssh/ssh-project.acks.test.ts + src/main/remote/relay-host-service.test.ts + src/main/remote/host-service.presence.test.ts + src/main/remote/standing-host.test.ts + src/main/remote/android-interop-fixture.guard.test.ts + + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Android tools for APK signing regression tests + run: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "platforms;android-35" "build-tools;36.0.0"' + + - name: Verify private beta packaging with real Android tools + run: NODETERM_ANDROID_TEST_SDK="$ANDROID_HOME" python3 -m unittest discover -s android/tools -p 'test_*beta*.py' -v + + - uses: actions/download-artifact@v5 + with: + name: nodeterm-android-release-unsigned + path: beta-inputs/apk + + - uses: actions/download-artifact@v5 + with: + name: r8-release-outputs + path: beta-inputs/r8 + + - name: Release input provenance + env: + BETA_VERSION_CODE: ${{ inputs.beta_version_code || env.NODETERM_BETA_VERSION_CODE }} + BETA_VERSION_NAME: ${{ inputs.beta_version_name || env.NODETERM_BETA_VERSION_NAME }} + run: | + python3 - <<'PY' + import hashlib, json, os, pathlib + def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + apks = list(pathlib.Path('beta-inputs/apk').glob('*-unsigned.apk')) + if len(apks) != 1: + raise SystemExit('Expected exactly one unsigned release APK from this run') + r8 = pathlib.Path('beta-inputs/r8') + pathlib.Path('beta-build-inputs.json').write_text(json.dumps({ + 'schemaVersion': 1, + 'sourceRevision': os.environ['GITHUB_SHA'], + 'versionCode': int(os.environ['BETA_VERSION_CODE']), + 'versionName': os.environ['BETA_VERSION_NAME'], + 'unsignedApkArtifact': 'nodeterm-android-release-unsigned', + 'r8Artifact': 'r8-release-outputs', + 'unsignedApkSha256': sha(apks[0]), + 'r8MappingSha256': sha(r8 / 'mapping.txt'), + 'r8SeedsSha256': sha(r8 / 'seeds.txt'), + 'signed': False, + }, indent=2) + '\n') + PY + + - uses: actions/upload-artifact@v4 + with: + name: nodeterm-android-beta-build-inputs + path: beta-build-inputs.json + if-no-files-found: error + + # CodeQL for the Android companion's Kotlin (audit A69): the protocol module (the NaCl box, the relay + # client, pairing, SSH host-key pinning) and the app (the Keystore-held secrets, the terminal WebView + # bridge). It is here rather than in security.yml beside the JS/TS `CodeQL` job (the required check, + # left as it was): security.yml runs only on pull requests into main, pushes to main, the merge queue + # and a weekly schedule, with no workflow_dispatch, so a job there cannot run from a feature branch and + # its first run ever would be on somebody's pull request, where a broken job shows red on every PR into + # main. Here it runs on this workflow's triggers: a push to main or a pull request that changes a + # file the path filter above lists, and workflow_dispatch. + # The trade-off: it follows that path filter. The Kotlin (all of it under android/) is analysed when + # Android-relevant files change: on a pull request that touches them and on main when such a pull + # request lands. Code scanning compares a pull request with the latest + # analysis on main of the same category, so a pull request that touches no Android file needs none of + # its own; its Kotlin is main's. Lost against security.yml's triggers: the weekly re-scan (a new CodeQL + # query reaches unchanged Kotlin only with the next Android change on main) and a merge-queue run. It + # is not a required check and must not become one: a pull request this workflow skips reports nothing + # for it, so a required one would wait forever. Feature-branch pushes do not duplicate PR runs; + # each admitted run uploads for its own ref. Not verified: + # whether GitHub's code-scanning check on a pull request that skips it reports main's java-kotlin + # configuration as not found (that is a warning on that check, not a failure). + # build-mode none would analyse nothing: for java-kotlin it reads Java sources only, and android/ is all + # Kotlin. So CodeQL traces a build of it (manual). `:app:compileDebugKotlin` compiles the app and, + # through settings.gradle.kts' includeBuild("protocol"), the protocol module's main sources (tests are + # not analysed); it needs the Android SDK that ubuntu-latest ships, as the app job above does. + # --no-daemon keeps the build in a process CodeQL traces; the in-process strategy keeps the Kotlin + # compiler inside it rather than in a separate Kotlin daemon (the property reaches an included build + # too: checked locally on tools/typecheck, which includes the protocol build the same way; with it no + # KotlinCompileDaemon starts, without it one does); + # --no-build-cache keeps a cache hit from skipping the compile CodeQL extracts from. + # setup-gradle validates the wrapper jar before ./gradlew runs and reads the Gradle cache the app job + # writes (see the note above `jobs:`). GradleCiCoverageTest (android/protocol) pins this job. + codeql-kotlin: + name: CodeQL (Kotlin) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic + cache-read-only: true + + - uses: github/codeql-action/init@v4 + with: + languages: java-kotlin + build-mode: manual + queries: security-extended + + - name: Build the Kotlin (app + protocol) under CodeQL + working-directory: android + run: ./gradlew --no-daemon --no-build-cache -Pkotlin.compiler.execution.strategy=in-process :app:compileDebugKotlin --stacktrace + + - uses: github/codeql-action/analyze@v4 + with: + category: '/language:java-kotlin' diff --git a/CLAUDE.md b/CLAUDE.md index b68110ba20..cacb40efd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -550,7 +550,9 @@ project's nodes only.** The contract: ## Terminal session continuity (tmux) `src/core/pty-manager.ts` runs each terminal inside a persistent tmux session -(`tmux new-session -A -D -s nt-`) on a dedicated socket (`-L node-terminal`) with +(`tmux new-session -A -D -s nt-`; `-D` is left off while a relay-served phone client of +that node is attached, so mounting the node does not detach the phone — `tmuxAttachFlags`, audit +A13) on a dedicated socket (`-L node-terminal`) with a generated config (`-f /tmux.conf`, so the user's `~/.tmux.conf` never interferes; status bar off, **mouse on**, 50k history, `set-clipboard on` + `terminal-features ",*:clipboard"`, and the copy-mode mouse bindings). Because the tmux *server* outlives the app, @@ -612,6 +614,15 @@ the user's own `tmux -L node-terminal attach`, a second nodeterm on the same soc painter). The session reaper subtracts ours via the `shadowed` seam — a shadow is a real client but not a watcher, so a shadowed session must stay exactly as cullable as an idle detached one. +**Background answers bind to a pane, not a changing selection.** `PtyManager.backgroundWrite` +resolves an exact `=session:` target to `%paneId`, cancels copy mode and sends to that ID, including +while its painter is mounted. Serialize the complete probe/cancel/send delivery per node so stream +chunks retain their order; unrelated nodes must not wait behind it. A control client consumes its +startup `attach-session` reply before resolving stdin commands. Each `command()` takes one tmux +command, with one reply block; do not combine the probe/cancel/send into a command list. Unconfirmed +delivery is never retried. Android's SSH script uses the same pane binding and requires exit status +zero; the unchanged relay verb benefits iOS too (continuation audit A78–A80). + The count is carried numerically rather than collapsed at parse time **because the subtraction needs it**: a session holding our shadow AND a real client must still read as attached, and a boolean could only be forced to false — reaping the session out from under whoever that other @@ -692,7 +703,12 @@ Lifecycle, by intent: DOM renderer until panned out and back). The NODE still never re-acquires itself (that loop is the eviction fight the design fears): the retry goes through `tryGrant` — never exceeds the budget, never reclaims a visible holder — and stops after `WEBGL_LOSS_STREAK_MAX` consecutive - losses (visibility transition resets). The node registers via `registerWebglClient` on mount + losses (visibility transition resets). A `webglcontextrestored` event makes the original context + live again: capture its addon canvas, dispose the addon, then explicitly retire that original + context via `loseWebglContexts` BEFORE reporting `handle.contextLost()` and allowing a fresh + grant. Addon disposal detaches the canvas and deletes resources but does not release the context's + Chromium cap slot. Keep the already-lost `onContextLoss` path separate; nodes never re-grant + themselves. The node registers via `registerWebglClient` on mount and `handle.dispose()`s on unmount (which releases + cancels timers). A parked terminal is off-screen so it holds no context. Permanent-delete paths call `disposeTerminalOnUnmount(id)` so a deleted node disposes instead of parking. @@ -987,7 +1003,10 @@ Lifecycle, by intent: (`dismissed: true`); clearing it would let Eco `/exit` a CLI whose cron wakeup lives in that process. (2) **Fire-time re-asks**: still-offscreen, remote, eligibility — a plan-time verdict is stale by seconds. (3) `hibernated` **self-heals** on live hook states + SessionStart (never - on `done` — a late Stop POST must not undo a just-performed hibernate); cold restore (`fresh`) + on `done` — a late Stop POST must not undo a just-performed hibernate), and so does the + agent-status mirror the phone reads SLEEPING (and offers a wake) from: the renderer reports its + clear, and the mirror applies the same rule to the hook events it records, renderer or not (a + codex SessionStart arrives as `working`, never as a session start); cold restore (`fresh`) clears `hibernated` UNCONDITIONALLY and normally lets auto-resume own the node — **`paused` (see below) is what makes that auto-resume itself conditional**, the one deliberate exception: the flag it gates is still cleared, only the relaunch is skipped. (4) **Ordering with offscreen @@ -9539,6 +9558,140 @@ For every OTHER test dir, two layers, both needed: ## Conventions +**Android output buffers and pending page commands belong to one viewer (A131).** Check reader +admission and delayed-flush ownership under the same lock; a retired callback must not drain a +replacement batch. Paint snapshots before later bytes, preserve the current ready page's final +tail before exit, and retire viewer-owned JavaScript still waiting for page readiness on reconnect, +background, renderer loss or disposal. Font setup and lifecycle suspension remain page-global. +This does not fence JavaScript already dispatched to the WebView or change a host wire contract. + +**Native history is inert and owned by one viewer (A129).** Advertise `pty.attach.scrollV1` +only for the served `pty.scrollV1 {streamId, dir, lines, viewId?}` route. Its `history` result +carries physical text/wrap/normal-or-alternate rows and viewer token/offset/total/cols/truncation/ +older/newer fields; `input`, `refused` and `uncertain` are separate outcomes. Bound capture to +1 MiB/8192 rows, pages to 256 KiB/200 rows, shared retention to 16 MiB with 60-second inactivity +expiry, and per-stream FIFO work to 32 actions. Move three physical rows per notch. A valid token +pages only its immutable snapshot: live app mouse-mode changes cannot steal history gestures +as foreground input. Live, actual user input, resize or an explicit new intent closes the view; +foreign/expired tokens refuse instead of adopting fresh history. On each new intent, cross the +actual output/geometry barrier and check the headless xterm 6 tracking/encoding before planning +zero-byte mouse-off history or requested default/SGR/pixel wheels. Require captured backend +lifetime and exact session-host generation, original subscriber and socket inside the deferred +send turn; negotiate `scroll-view-v1`, never restart an old host or replay uncertain input. +Keep known-tmux/direct-SSH wheel ordering and momentum. Android's separate inert layer must keep +the shipped renderer xterm 5.5 live parser; actor/page/display epochs fence late pages, and Copy/ +links read visible rows. Preserve automatic terminal reports when clearing user-input history. +Update host, Android and actual producer interop together and flag iOS adoption for @eneskirca. +Recorder/component tests prove their named boundaries, not physical ConPTY or phone acceptance. + +**Explicit composed Send keeps one captured backend owner (A128).** The public phone +`pty.submitComposed` contract routes attached local streams to their exact tmux viewer receipt, +captured direct `NativeWindowsPane`, or captured `SessionHostPty`. The internal v2 +`composed-input-v1` feature negotiates prepare/write/cancel: a host-minted ticket is one-use, +expires after 10 seconds, and binds the actual `HostSession` generation and subscriber socket. +Its lock spans paste and a separately guarded Enter, with both host and client enforcing the +150 ms minimum. Check lifetime after the actual emulator output/mode barrier and immediately +before every native write. The client checks its original subscriber registration and socket +inside the real deferred send turn; a replacement socket cannot redeem the ticket. A phase +that may have reached the socket is uncertain on missing receipt/RPC error, never replayed. +Cancellation/expiry/detach only consume tickets. Older live hosts refuse without restart or +name-only input fallback. Busy/stale attachment errors do not prescribe an update. This is +explicit terminal input, separate from agent process attestation and observed-screen messages; +a positive receipt proves terminal writes, not that its application executed the command. +The outer Android action/result remains unchanged, with actual backend producer interop owed; +flag additional iOS availability and retention adoption for @eneskirca. + +**Co-view terminal replies have one live owner (A126).** Within one renderer, scope automatic +xterm responses to the exact `api.pty` object and actual session id, with live promotion on +exit/closed/recycled subscription or final disposal. Preserve genuine keyboard/paste/SGR mouse +input and actual DOM focus/blur from every view; automatic DEC1004 focus-state replies prefer the +connected textarea that is its owner document's active element. Canvas park retains the +subscription/lease, and adoption must not rewrap or rebind it. Pin origin regressions to actual +xterm 5.5. An unsupported private shape must warn and preserve input through a public fallback; +that fallback can still duplicate replies. This renderer-local invariant claims no cross-window +or Server-client ownership guarantee and changes no Android/iOS wire contract. + +**Retained history search is host-owned (A100).** `pty.historySearch` addresses an existing stream, +not caller-supplied node paths. Capture the attached retained generation or exact resolved tmux +pane; reject replacement during capture. Search literal, case-sensitive text with bounded query, +spool and answer sizes, and return line numbers/truncation honestly. Do not turn an unsupported +older session host into a replacement/restart or an empty success. Native STOP/dispose retires the +history sheet; late results never migrate to a replacement stream. SessionSearch and captured +TerminalCopy search remain local; retained TerminalHistory search runs on the computer. + +**Interrupted backend reads may be resent; uncertain writes may not (A101).** The additional +session-host retry policy allows only the named typed read operations after `EPIPE` or +`ECONNRESET`. It does not replay sent writes, permission refusals, deadlines or unknown errors. +Keep the existing separate proof for frames never handed to `socket.write`, the bounded attempt +count and the original transport error if reconnect cannot proceed. + +**Cold relay launch prepares trusted owner facts before accepting input (A102).** Resolve the saved +node's project/account/agent and trust-aware project env/shell on the host. A known ineligible or +ambiguous saved node cannot be reassigned by phone hints. Preparation spawns nothing; recheck warm +races before its synchronous response/snapshot/attach commit. Joining a warm pane does not change +its launch facts or owner. Never leave an asynchronous settings read between SnapshotEnd and input +routing. Keep reserved hook environment filtering and shared executable-content trust gates. + +**Permission policy belongs to the agent and host that will run it (A103).** Android launch, cold +resume and Sleeping wake use the desktop's measured approval dialects. Only Claude's auto flag +uses its own capability gate; Codex values use that host's advertised vocabulary and measured +unknown-host baseline. Unsupported modes use the CLI default, never a guessed looser equivalent. +Do not fold a sandbox bypass into an approval-mode mapping or borrow local capabilities for SSH. + +**Offscreen wake never creates or adopts a pane (A104).** Resolve one saved project/node and its +actual session id, launch override, approval policy and recorded WakeContext without switching +projects. Fence saved state and the owning live/released backend across awaits; require the exact +pane/process/generation before a single delivery. Automatic wakes honor explicit Pause. Retry only +an unreadable pre-write observation; never retry uncertain input, attach a fresh shell, use an +ordinary name-only write or replace an older backend to add this capability. + +**Held replies use the original request, not prompt numbering (A105/A106).** New remembered rules +must be concrete eligible original addRules suggestions with exact tool/content/destination, +confirmed in the UI; never infer an Always allow digit or change permission mode. Held questions +carry every question and option, submit all selected indexes, preserve original tool input and +rederive exact labels on the computer. Omit legacy numbered choices on held v2 cards so older +phones open the session. Recheck still-open ticket/card ownership and schema, stage replies, and +repeat the request identity/checksum guard after streamed stdin immediately before rename. +Structured cards settle only on the hook's consumed-answer POST. Missing, changed, expired or +unsupported requests never fall back to keys or pretend success; legacy allow/deny remains intact. +These mirror/verb/SSH changes owe Android interop and an iOS adoption note for @eneskirca. + +**Direct SSH Git stays typed and jailed (A107).** Reuse the existing eight GitVerb/result contracts +for listed local or driven-project folders on the selected computer; refuse third-machine targets. +Recheck physical cwd and repository root under admitted physical project roots, use quoted argv +and literal pathspecs, and keep credentials/hooks/signing on that SSH account. Bound output and +require confirmed status. No automatic replay or relay fallback follows an uncertain write. A +push may set upstream only after the exact native exit-128 missing-upstream diagnostic for the +branch observed before that push; remote/hook rejection text and transport failures authorize no +second attempt. The existing branch/discard/init/publish/merge-resolution gaps are unchanged. + +**SSH actions belong to one live selected profile (A108).** Advertise only implemented methods, +with an instance nonce and host-clock heartbeat. Claim one selected profile atomically before +advertising; retain its lifetime ownership until in-flight work and cleanup finish. Retirement is +monotonic, and cleanup cannot remove a successor. Accept bounded private regular owned files; +never use the hook bearer. Board mutations pass through WorkspaceStore's actual save queue and +final live-instance/node fences. Deduplicate immutable request nonces within their bounded retry +window; a lost/expired receipt is uncertain and never authorizes another mutation or relay replay. +Server has no renderer node nudges; Desktop may acknowledge their delivery. Do not equate delivery +with a completed wake/repaint or implement cold managed New by writing metadata alone. + +**Managed SSH New is a host-owned launch transaction.** `sessions.createManagedV1` accepts only +a creation UUID, owned project id, shell/builtin-agent choice, optional local account/title and +bounded terminal size. The host resolves one open local folder project from WorkspaceStore; +SSH, driven, inline, closed and ambiguous owners cannot become local launches. Derive executable +input through the canonical command assembler and the host's trust-aware project settings, +actual CLI capabilities, account directory, permission policy, model and shared Codex identity. +Never accept a phone command/cwd/shell/model/permission override or default away a missing selected +account. Account absence means an explicit System choice; freeze a displayed default by sending +its id. Revalidate these facts across awaits. +Keep expanded launch text host-private; shared recovery intent must not serialize environment +values or launch secrets. Both Desktop and Server advertise creation only with real tmux support, +after the workspace has loaded. An exclusive exact-pane receipt, current +instance fence and durable coordinator phases govern spawn/register/launch; a persisted receipt +cannot restore runtime ownership or authorize a replay. Unknown delivery stays recoverable and +uncertain, never a second launch or a legacy fallback. Keep Android producer/client interop in +the same change and flag the additive SSH method/receipt for @eneskirca in iOS. + - **Two docs, two audiences — keep both.** This file holds the deep invariants with their reasoning and measurements; it is dense on purpose and is loaded automatically by coding agents. **`CONTRIBUTING.md` is the short human door**: setup, the process-boundary rules, the house rules @@ -9583,6 +9736,93 @@ For every OTHER test dir, two layers, both needed: pairing/relay/mirror wire contracts, so a desktop change must not assume the phone is an iPhone (copy, defaults, store links — see **Phone pairing is platform-neutral**). + **The Android companion in this contribution lives in THIS repo** (`android/`, docs/android.md): a + pure-Kotlin wire layer (`android/protocol`, JVM-tested against this repo's own + `connectHostSession` / `createPairingService` through a local broker, and against a real SSH + server + sandboxed tmux) under a Compose app. It speaks exactly what the standing phone host + serves — the legacy relay dialect of `host-service.ts` and the direct-SSH/tmux conventions — + so **a change to a `host-service.ts` verb, the `projects.list` blob, the pairing payload, the + mirror file, or the SSH-visible file contracts (`~/.nodeterm/pending`, `~/.nodeterm/acks`, + `~/.nodeterm/relay.json`, and the per-project status slices + `~/.nodeterm/agent-status-.json`, read with the `nodeterm-rmt` sessions and + `/.nodeterm/project.json` by a phone SSHing into a desktop-driven host, audit A27) + owes the Android client (and its interop fixture, + `android/protocol/src/test/interop/host-fixture.ts`) in the same PR**; the Android workflow + (`.github/workflows/android.yml`) runs on those paths: its filter covers every input of the + fixture's esbuild bundle (`src/core`, `src/shared`, `src/main/*.ts`, `src/main/remote`), which + also holds the producers the bundle leaves out (`src/core/ack-sweep.ts`, `src/shared/pair-qr.ts`, + and `src/main/index.ts`, which names the blob's sources), and `WorkflowPathFilterTest` fails on + a bundle input the filter misses (audit A63). Running is not testing, so know what is real + (audit A64): the relay tests run `connectHostSession`'s handshake and verb routing (the pty, + board, inbox and node-action bridges behind the verbs are fakes; `git.*` runs the real + `GitService` over a temp repository in the project's folder, audit A29), `createPairingService`, and + the `projects.list` blob as the desktop builds it — `buildProjectsListBlob` + (`src/core/projects-list-blob.ts`, the one assembly `listProjectsOutput` also calls) over a real + `WorkspaceStore` and an `agent-status.json` the real mirror wrote from hook payloads + (`android-interop-fixture.guard.test.ts` refuses a hand-written blob or mirror there). The SSH + leg retains hand-written legacy/adversarial browse and held-file cases. Newer `SshTransportTest` + Server-profile cases read actual Server config/platform, workspace and mirror publications over + private SSH; this component fixture does not run full Server boot, installed hooks or account + probes. `SshActionsInteropTest` and `ManagedSessionInteropTest` also run selected-profile services + and Kotlin writers, with the managed fixture naming its native process/CLI recorder boundary. + Two relay-advertisement cases run the actual account-level file writer/remover and SSH parser + with a fixture-only OS-home adapter. They check every field, replacement, removal and profile + isolation; they do not mint a token or establish adoption, SAS approval or revoke. Driven status + slices and legacy/malformed cases still need their matching hand-maintained fixtures. `AckSweepInteropTest` additionally runs the actual Android `.seen` + producer against the desktop's local/remote consumers. Those consumers require positive + ownership before reading/deleting files; remote ownership aggregates all projects on a host. + The ack-only fixture shares its implementation with `host-fixture.ts` and needs no relay + sockets/crypto dependencies. Keep the format and consumer behavior compatible with iOS. + **Phone-owned plain SSH shells (`A90`)** use the separate `nodeterm-phone` + socket and creation marker/session metadata, listed under Phone terminals. They do not + write shared `project.json`, register canvas nodes or carry managed-agent hook identity. + Keep desktop/Server Edition socket scans, reapers and kill fanout on their own + `node-terminal` / `nodeterm-rmt` sockets; clear stale inherited `NODETERM_*` in the new pane. + Cold-agent SSH refusal (`A08`) and relay canvas New session remain. This feature changes + no current RPC/blob/pairing/mirror/file contract; mention **@eneskirca** for iOS adoption + of the isolated socket and marker/metadata. Host regressions/build are verified in + the beta-8 baseline; beta 10/code 11 is installed. Focused Pixel plain-SSH creation/history/ + restart/update/reconnect/exact End pass (item 32 Partial), and focused relay plain-shell + creation/cwd/input pass. Managed-agent and cellular creation and the full device matrix + remain pending; the focused A91 otherwise-empty-host cycle passes. + Do not infer full readiness. + **Listing failures (`A91`)** clear the cached phone listing only for authoritative SSH + `NothingFoundException`; other failures retain it and cancellation propagates. Preserve + the route-specific error and connected SSH state on an empty host. A completed authoritative + empty snapshot must carry its fetch time (`A94`): zero is the initial loading sentinel and + leaves the UI saying Loading sessions indefinitely even after the fetch finishes. This is client policy, + with no RPC/blob/pairing/mirror/SSH-visible file contract change. + The fixture implements host-service's + interfaces (`HostPtyManager`, the kanban/inbox/nodeActions bridge) and esbuild only strips its + types, so it sits in `tsconfig.node.json` and `npm run typecheck` checks it on every CI run; + it passes nothing through a cast (`android-interop-fixture.guard.test.ts`, audit A67). Two + relay verbs exist for a relay-only phone and must not be dropped: `approvals.answer` (answers + a held hook-reply approval through the SAME `answerPermission` the canvas button uses — typing + `1` is wrong there, the prompt is not on screen while the hook holds it) and `inbox.ack` (the + read-ack the `~/.nodeterm/acks` sweep runs for an SSH phone). An exact-match tmux PANE target + is `=name:`; `=name` alone is refused ("can't find pane", measured on 3.4) — session commands + take `=name`. + + Core-origin phone Board writes announce `workspace:server-change` (`A95`). The existing + renderer consumer adopts kanban metadata live and preserves unsaved canvas edits, so a later + ordinary save retains both. `workspace:external-change` is for outside-file edits: the dirty + canvas conflict path can otherwise leave a mounted Board stale behind its conflict strip. + Desktop preload must provide the real subscription and unsubscribe for this channel too; its + former Server-only no-op would discard every repaired core event. Keep actual outside-file + conflict handling intact; this distinction changes no phone contract. + + Android's merged All computers screen starts each foreground watcher with `Trigger.AUTO`, + preserving refused or unanswered relay approvals; opening an individual host keeps `USER`. + Serialize the full connect/list/publish/announce path per host. STOP cancels that watcher's poll + and reconnect/change work, while counted background users and another visible screen retain + the connection. An old cancelled watcher must never close a newer watcher’s connection. + Search remains local: SessionSearch preserves node ids/project groups, and TerminalCopy search + uses the captured rows' original UTF-16 offsets without changing selection or sending input. + SSH Include discovery reads bounded config files and public host-key files, with cycle, glob, + depth and shared work budgets; never open a private HostKey as an included config. Included + fingerprints still ride the sealed existing pairing/LAN fields, so the Android interop fixture + and anchor tests are owed with discovery changes; flag iOS verification for @eneskirca. + **The canvas and the kanban board are TWO VIEWS of the same nodes — treat the board as a first-class surface, not an afterthought.** Every session/node feature you add to a canvas node (a header action, a context-menu item, a status badge, file drop, dictation, …) should be @@ -9701,3 +9941,19 @@ partial/unknown delivery, with no resend. The SessionStart idle rescue latch sto agent and receive time; foreign/missing idle identity never creates proof or changes the renderer-visible session. These boundaries have behavioral regressions in `core/windows-delivery-safety.test.ts` and the mirror/client suites. + +## Canvas terminal mouse-leave ordering + +Canvas mouse leave defers only xterm blur to the next task (`terminal/deferred-blur.ts`). +xterm 5.5's DOM renderer redraws its rows synchronously on blur; doing that during React's +mouseout handling can detach the native target before screen mouseleave clears the link cursor. +Do not replace the timer with a microtask. Cancel pending blur on re-entry, intentional focus +and lifecycle cleanup before parking; a parked Terminal can be adopted by another component. +Status/presence release stays synchronous. Behavioral tests cover cancellation and ownership; +eight native Linux Desktop cases pass at `01b4a2f5`, with the immediate-blur and old-cap +mutants caught and restored control passing. Synthetic SGR, DOM rendering and explicit disposable +fixture exit do not verify real tmux/SSH, GPU, macOS, ordinary app quit or Android. Separate +eight-case Linux Server control/restored runs at `dcdf664a` also catch both native mutants through +the shipped Node entry and authenticated HTTP/WebSocket bridge. Chromium has no Desktop preload +or injected API; passive observation verifies real PTY create/reply/output, and owned child cleanup +passes. This remains DOM/synthetic-TUI verification, with no physical checklist promotion. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d258adf6aa..824d58c830 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,6 +93,15 @@ verify and stop any remaining host. Never recommend **End session** (it deletes resume depends on supported, saved conversation history; it does not preserve running tasks. See `docs/windows-session-host.md` for the user-controlled preparation/recovery steps and limits. +**Explicit phone Send is a captured-terminal transaction.** The public `pty.submitComposed` +request/result stays the same across local tmux, direct native Windows PTYs and negotiated +session hosts. `composed-input-v1` preparation mints one-use tickets bound to the live session +generation and original subscribed socket; it never redeems a ticket on a replacement. +Check lifetime inside the deferred send turn and after the actual emulator mode barrier. +Paste and Enter are separate writes at least 150 ms apart. Sent-phase errors are uncertain, +never automatic retries. Older live hosts refuse without being replaced or restarted. +This explicit input path is separate from observed-screen agent-message submission. + **Prepare for update** (#829 step 2, Windows only): ⌘K / the update card run `components/PrepareUpdateDialog.tsx` over the pure `lib/updatePrep.ts` plan. It refuses while any session's agent is working or waiting on the user (renderer store OR core mirror — unmounted nodes @@ -126,11 +135,77 @@ applicable here": would need, and **mention @eneskirca** so it gets picked up there. "Not applicable" is a fine answer — just make it a stated one. Never assume the phone is an iPhone in desktop copy or defaults. + The **Android** companion is in this repo (`android/`, see docs/android.md). If you change what + a phone sees — a `host-service.ts` relay verb, the `projects.list` blob, the pairing payload, + the agent-status mirror (including the per-project `~/.nodeterm/agent-status-.json` + slices an SSH host gets), or the `~/.nodeterm/{pending,acks,relay.json}` files — update the + Android client in the same PR. `./gradlew -p protocol test` (from `android/`, on JDK 17–24, + after your usual `npm install`) runs the relay leg against this repo's own host code: the handshake + and host-service's verb routing (`connectHostSession`; the pty, board, inbox and node-action + bridges behind the verbs are fakes, while `git.*` runs the real `GitService` over a temp + repository), pairing (`createPairingService`), and the `projects.list` + blob as the desktop builds it (`buildProjectsListBlob` in `src/core/projects-list-blob.ts` over + a real `WorkspaceStore` and an `agent-status.json` written by the real mirror). Legacy/adversarial + SSH browse and held-file cases still use hand-written fixtures. Newer `SshTransportTest` + Server-profile cases read actual Server config/platform, workspace and mirror publications over + private SSH; this component fixture does not run full Server boot, installed hooks or account + probes. `SshActionsInteropTest` and `ManagedSessionInteropTest` also run selected-profile services + and Kotlin writers, with the managed fixture naming its native process/CLI recorder boundary. + Two relay-advertisement cases run the actual account-level file writer/remover and SSH parser + with a fixture-only OS-home adapter. They check every field, replacement, removal and profile + isolation; they do not mint a token or establish adoption, SAS approval or revoke. Driven status + slices and legacy/malformed cases still need their matching hand-maintained fixtures. `AckSweepInteropTest` additionally runs + the actual Android `.seen` producer against the desktop's local/remote consumers: each consumer + requires positive ownership before reading/deleting a file, and remote ownership is the union + of all projects on that host. Keep this behavior compatible with iOS. + Android phone-owned plain SSH shells (`A90`) use dedicated `nodeterm-phone` + session metadata, without shared project-file writes, canvas registration or managed hook + identity. Keep desktop/server socket scans/reapers/kill fanout on `node-terminal` and + `nodeterm-rmt`; strip inherited `NODETERM_*` in a new phone pane. Cold-agent SSH refusal and + relay canvas creation remain. No existing RPC/blob/pairing/mirror/file contract changes; + flag isolated socket/creation-marker/metadata adoption for **@eneskirca** in iOS. Host regression + and build checks pass in the beta-8 baseline; beta 10 / code 11 is now installed on the intended + Pixel. Focused plain-SSH creation/history/restart/update/reconnect/exact End pass (item 32 Partial); + Focused relay plain-shell creation/cwd/input also pass. Managed-agent and cellular creation + and the full device matrix remain pending; the focused A91 otherwise-empty-host cycle passes. + Listing failures (`A91`) clear cached rows only for authoritative SSH `NothingFoundException`; + other failures retain them and cancellation propagates. Preserve the route-specific error and + connected SSH state on an empty host. Stamp a completed authoritative empty snapshot with its + fetch time (`A94`); zero means the initial loading state. This client policy changes no shared + host contract. + The Android interop fixture + (`android/protocol/src/test/interop/`) implements host-service's interfaces and is part of + `npm run typecheck`, so changing one of them fails the typecheck there until the fixture follows. + The Android workflow runs on every file that fixture bundles (`src/core`, `src/shared`, + `src/main/*.ts`, `src/main/remote`); an import that pulls a file from elsewhere into it fails + `WorkflowPathFilterTest` until the workflow's `paths` list it, and so does a protocol test that + reads a repo file outside `android/` (name it by a literal path from `InteropHarness.repoRoot`; a + computed path fails that test unless its `computedReads` says why it is covered). Do not copy CI's + `npm ci --ignore-scripts` onto a desktop checkout; it is for a machine without the native + toolchain. `npm ci` deletes `node_modules` first and the flag skips the node-pty patch and build, + so node-pty stays unpatched (unbuilt on Linux) until `npm install` or `npm run rebuild` runs again. Anything reachable from `window.nodeTerminal` needs a **real** implementation in `src/renderer/bridge/`, or a deliberate, documented degrade. The `satisfies NodeTerminalApi` gate forces you to *declare* every member, but a no-op stub compiles fine while doing nothing. +Background answers must resolve the exact tmux session to a pane ID, cancel copy mode, and type +into that same ID. Preserve per-node write order across the whole delivery and never retry an +unconfirmed send. Control clients consume the startup attach reply before matching stdin replies; +each control command gets one reply block, so do not combine multiple commands into one call. +Android's SSH transport also requires exit status zero before reporting an answer sent. + +Phone Board writes made by this core use `workspace:server-change` so the mounted Board adopts +them while retaining unsaved canvas edits (`A95`). Keep `workspace:external-change` for outside +file edits: a dirty canvas may correctly ask the user to resolve those conflicts. Desktop preload +must subscribe to the internal channel too; a Server-only or no-op bridge leaves Desktop stale. + +The Android All computers watcher uses automatic approval gating, serializes per-host refreshes, +and cancels its poll/reconnect work on STOP without closing another screen or background user's +connection. Keep session/output search local and retain original node/row identities. SSH anchor +discovery follows bounded Includes and reads public keys only; changes require pairing/LAN interop +coverage and an iOS follow-up for @eneskirca even when the field format stays the same. + The **canvas and the kanban board are two views of the same nodes.** When you add something to a canvas node — a header action, a badge, a menu item — ask whether the board's card and card modal need it too, and wire it in the same change. The global (Omni) board shows all open projects as @@ -229,6 +304,62 @@ anything else. Board-level fields survive every transform — `pullLinks` is one ## House rules +- **Native history is inert and owned by one viewer (A129).** Advertise `pty.attach.scrollV1` + only for the served `pty.scrollV1 {streamId, dir, lines, viewId?}` route. Its `history` result + carries physical text/wrap/normal-or-alternate rows and viewer token/offset/total/cols/truncation/ + older/newer fields; `input`, `refused` and `uncertain` are separate outcomes. Bound capture to + 1 MiB/8192 rows, pages to 256 KiB/200 rows, shared retention to 16 MiB with 60-second inactivity + expiry, and per-stream FIFO work to 32 actions. Move three physical rows per notch. A valid token + pages only its immutable snapshot: live app mouse-mode changes cannot steal history gestures + as foreground input. Live, actual user input, resize or an explicit new intent closes the view; + foreign/expired tokens refuse instead of adopting fresh history. On each new intent, cross the + actual output/geometry barrier and check the headless xterm 6 tracking/encoding before planning + zero-byte mouse-off history or requested default/SGR/pixel wheels. Require captured backend + lifetime and exact session-host generation, original subscriber and socket inside the deferred + send turn; negotiate `scroll-view-v1`, never restart an old host or replay uncertain input. + Keep known-tmux/direct-SSH wheel ordering and momentum. Android's separate inert layer must keep + the shipped renderer xterm 5.5 live parser; actor/page/display epochs fence late pages, and Copy/ + links read visible rows. Preserve automatic terminal reports when clearing user-input history. + Update host, Android and actual producer interop together and flag iOS adoption for @eneskirca. + Recorder/component tests prove their named boundaries, not physical ConPTY or phone acceptance. + +- **Co-view terminal replies have one live owner (A126).** Within one renderer, scope automatic + xterm responses to the exact `api.pty` object and actual session id, with live promotion on + exit/closed/recycled subscription or final disposal. Preserve genuine keyboard/paste/SGR mouse + input and actual DOM focus/blur from every view; automatic DEC1004 focus-state replies prefer the + connected textarea that is its owner document's active element. Canvas park retains the + subscription/lease, and adoption must not rewrap or rebind it. Pin origin regressions to actual + xterm 5.5. An unsupported private shape must warn and preserve input through a public fallback; + that fallback can still duplicate replies. This renderer-local invariant claims no cross-window + or Server-client ownership guarantee and changes no Android/iOS wire contract. + +- **Android queued output belongs to one viewer (A131).** Fence buffered bytes, delayed flushes + and commands waiting for page readiness by their original viewer. Paint before later bytes; + preserve the ready page's final tail before exit. Keep font setup and suspension page-global. + This does not fence JavaScript already dispatched to the WebView. + +- **Android contracts change together.** Keep host verbs, project/mirror/pairing payloads, + SSH-visible files and Android interop in the same change; flag iOS adoption for @eneskirca. + Retained history search addresses the attached generation/exact pane with bounded literal + results; stale queries/streams cannot publish into a replacement screen. Cold relay launch + resolves saved owner/account/agent and trusted project env/shell before accepting input. Use + that agent/host's measured permission dialect. Offscreen wake requires saved ownership and + exact process/generation proof; honor Pause and never create/adopt a pane or replay uncertain + input. Held rules/questions use the original hook request and guarded answer file, with no + guessed digits or arbitrary rule JSON; iOS must adopt the v2 fields/verbs together. + Direct SSH Git stays typed and physically jailed; remote rejection text never authorizes a + second push. SSH Board/node actions use the selected live profile's private typed service and + actual WorkspaceStore save queue. Missing receipts are uncertain; Server has no node nudges, + and canvas New is not a metadata-only write. Managed SSH creation is one host-owned transaction: + resolve an open owned local project, valid local account and actual CLI capabilities; derive + commands/env/shell from trusted host settings. The request supplies identifiers and display/size + choices only. Account absence means System; send a displayed default's id to freeze that choice. + Expanded launch text stays host-private, outside the git-shared recovery intent. + Revalidate across awaits, require exclusive exact-pane proof, and never replay an + uncertain launch. Advertise this method only after workspace load with supported tmux, on both + Desktop and Server. Update Android interop and flag the additive method/receipt for iOS. + See CLAUDE.md for the deeper invariants. + - **Branch labels describe a checkout on one core.** Share existing status reads through `renderer/state/gitBranches.ts`; do not cache a branch forever by project id or copy a project branch onto worktree nodes. Source, Sessions and worktree headers consume the same observations, @@ -1189,6 +1320,8 @@ there, and esbuild fails on a `.node` require it cannot resolve (or, where the a on a file it has no loader for). No CI job builds the server bundle, but `src/server/server-build.test.ts` builds it in both views and goes red. The server runs from a `node_modules` tree, so an external is resolved at runtime. +On a desktop checkout, `npm ci --ignore-scripts` deletes `node_modules` and leaves `node-pty` +unpatched and unbuilt; run `npm install` or `npm run rebuild` before launching the desktop. ## Testing @@ -1304,6 +1437,9 @@ in CLAUDE.md § Idle energy. ## Pull requests - Branch from `main`. CI runs `quality`, `CodeQL` and `Dependency review`; all three are required. + `CodeQL (Kotlin)` (the Android companion's Kotlin, built under CodeQL, in + `.github/workflows/android.yml`) runs on a PR that changes an Android-relevant file, but is not one + of them. - Explain **why**, not just what. If a decision has a trade-off, name it and say what you rejected. - If you measured something, put the numbers in — they save the next person the same afternoon. - Say what you did **not** verify. That is more useful than a confident summary. @@ -1406,6 +1542,34 @@ a transmitted text request is uncertain delivery, never a pre-paste refusal; sho warning. SessionStart idle rescue is scoped to that same nonempty session and agent identity, and a foreign idle must not broadcast fresh state proof to the renderer. +## Retired WebGL contexts + +A restored xterm WebGL context is live again. When its owning Canvas listener replaces the addon, +explicitly lose the captured original addon canvas after disposal and before notifying the budget +coordinator. Querying the screen after disposal misses the detached canvas; disposing addon-webgl +0.18 alone does not retire its context. Keep the already-lost `onContextLoss` path and delayed, +budget-gated regrant policy unchanged. Behavioral helper tests cover retained detached canvases and +fail-open retirement. Native restored-hook verification must separately observe the trusted restored +event, old-context retirement, readable DOM fallback and a distinct fresh renderer; visible recovery +alone does not prove that budget slots were released. Preserve stale-addon ownership guards and do +not infer repeated/page-wide pressure coverage from a one-terminal pilot. + +## Canvas terminal mouse-leave ordering + +Canvas mouse leave defers only xterm blur to the next task (`terminal/deferred-blur.ts`). +xterm 5.5's DOM renderer redraws its rows synchronously on blur; doing that during React's +mouseout handling can detach the native target before screen mouseleave clears the link cursor. +Do not replace the timer with a microtask. Cancel pending blur on re-entry, intentional focus +and lifecycle cleanup before parking; a parked Terminal can be adopted by another component. +Status/presence release stays synchronous. Behavioral tests cover cancellation and ownership; +eight native Linux Desktop cases pass at `01b4a2f5`, with the immediate-blur and old-cap +mutants caught and restored control passing. Synthetic SGR, DOM rendering and explicit disposable +fixture exit do not verify real tmux/SSH, GPU, macOS, ordinary app quit or Android. Separate +eight-case Linux Server control/restored runs at `dcdf664a` also catch both native mutants through +the shipped Node entry and authenticated HTTP/WebSocket bridge. Chromium has no Desktop preload +or injected API; passive observation verifies real PTY create/reply/output, and owned child cleanup +passes. This remains DOM/synthetic-TUI verification, with no physical checklist promotion. + Control and linked-context fallback must keep a known node identity on the endpoint family the session was born on. A foreign server being reachable is not evidence that it owns the canvas — and a matching token is not proof either (on an SSH host the token dir is shared per unix diff --git a/README.md b/README.md index 218cf58e79..dab3704740 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ turns that into a **map**: every shell is a node you can place, group, label, an into. Sessions are spatial and persistent, so your mental model stays intact across restarts. And because the app is built around a clean service seam, the same canvas runs three ways — as the **desktop app for macOS, Linux and Windows (beta)**, as a **self-hosted browser app** -you reach from anywhere (Server Edition), and an **iOS companion** that attaches to the -same live sessions. +you reach from anywhere (Server Edition), and **phone companions for iOS and Android** that +attach to the same live sessions. 📚 **Full documentation lives at [nodeterm.dev/docs](https://nodeterm.dev/docs)** — get started, concepts, agents, remote access, troubleshooting. @@ -100,7 +100,7 @@ assign teammates. Toggle with `⌘⇧B`. ### Your sessions, anywhere -**Pair your phone** with one QR — *scan with the nodeterm iOS app* — and the **same +**Pair your phone** with one QR — *scan with the nodeterm app on iPhone or Android* — and the **same live session continues in your pocket**, E2E encrypted **over the relay, not just your LAN**. The same canvas also runs self-hosted in any browser (Server Edition). @@ -148,9 +148,9 @@ Gemini / GitHub Copilot / opencode / Grok / custom) · 📝 **Sticky note** (lin [setup and security details](./docs/github-issues-kanban.md). - **AI commit messages & terminal names** — bring-your-own local agent CLI run read-only on the staged diff or captured output. -- **Your sessions, in your pocket** — **nodeterm mobile** (iOS) attaches to the same live - tmux sessions: watch an agent work, answer a "needs you", or type into any terminal - from your phone — plus push notifications and a mobile board view. +- **Your sessions, in your pocket** — **nodeterm mobile** (iOS, and the Android app in + [`android/`](./android)) attaches to the same live tmux sessions: watch an agent work, answer a + "needs you", or type into any terminal from your phone — plus notifications and a mobile board view. - **Power & sleep** — while an agent is working, nodeterm keeps the machine from idle-sleeping, and lets go the moment it finishes (on by default; toggle in the setup tour or Settings → Behavior). No app can hold a machine awake through a closed lid — @@ -223,6 +223,12 @@ detects your platform. Everything is also listed at [report what breaks](https://github.com/eneskirca/nodeterm/issues). - **iOS** — **nodeterm mobile** on the [App Store](https://apps.apple.com/app/nodeterm/id6790581233). +- **Android** — build it from [`android/`](./android) (`./gradlew :app:assembleDebug`); CI also + attaches a debug APK to every run of the Android workflow. Debug builds share a public signing + key so a newer APK installs over an older one; install them only from this repository's CI or + your own build. They are also debuggable, so anyone with adb access to the unlocked phone can pull + its pairing credentials ([`android/README.md`](./android/README.md#security)); there is no signed + release build yet. Not in a store yet, and not yet tested on a device. **Trying it out?** Removal is one script — it stops every process nodeterm started, reverts the status-hook/skill entries it merged into your agent CLIs' config (your own hooks and @@ -354,7 +360,8 @@ on macOS** and applies to both canvas and kanban terminals. - **React Flow is the single source of truth** for live nodes; projects persist serialized nodes to disk, and tmux keeps sessions alive across restarts. - **Three surfaces** — the desktop app, the browser **Server Edition**, and the - **mobile companion** (a separate SwiftUI repo) all ride the same core + transport seams. + **mobile companions** (iOS in a separate SwiftUI repo, Android in [`android/`](./android)) all + ride the same core + transport seams. See [`docs/SERVER.md`](./docs/SERVER.md) for the Server Edition, and the design docs under [`docs/`](./docs) for deeper notes. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000000..526c1c2122 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +# Gradle / Android build output +.gradle/ +build/ +**/build/ +local.properties +.idea/ +*.iml +captures/ +.externalNativeBuild/ +.cxx/ +*.keystore +!debug.keystore +.kotlin/ +__pycache__/ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000000..59c62a6548 --- /dev/null +++ b/android/README.md @@ -0,0 +1,2015 @@ +# nodeterm for Android + +The Android companion to nodeterm: pair your phone with nodeterm on your computer, then watch your +agents, answer their questions and open any terminal on the canvas — on your network over SSH, or +from anywhere through the end-to-end encrypted relay. It is the Android counterpart of the iOS app +and speaks the same protocol to the same desktop; nothing on the computer needs to know which phone +it is talking to. + +**Upstream PR conflict integration (2026-10-08).** The Android contribution now incorporates +upstream `main` at `adde5e85` (v0.4.2), retaining the phone's typed host verbs, managed terminal +ownership, SSH-visible files and marker-framed v2 answers alongside upstream chat, role-scoped +pairing and session backends. Android also recognizes upstream Antigravity and matches its +`agy` launch/resume grammar without inventing approval flags. The merged managed hook uses revision 6: older revision-5 hooks +are not admitted for upstream unmarked structured answers, while legacy phone answers keep +their separate contract. The current merge has its own offline, desktop and CI checks; it does +not change the source binding of the prepared beta23 APK or the last verified installed +beta22/code23. Merged-source device acceptance remains pending, and the original +**10 Pass / 22 Partial / 32 Pending** ledger is unchanged. **iOS implications for @eneskirca:** +the upstream chat/role changes and new structured-answer revision gate are shared host behavior; +the existing Android/iOS phone verbs and v2 framing remain compatible. + +**Pending Send after remount (2026-10-08, A137 — published; beta23 prepared).** +Pending Send state and guidance belong to the retained entry, with exact button/IME admission and +uncertainty preserved until dismissal. Source `b9e4cbc7` passes focused66/9, thirteen intended mutants, +fresh1069/116/offline app and CI37740459063 all-five/ten-step. Retained-signer beta23/code24 is +prepared and reviewed with 46 available SDK36/R8/metadata checks; actual SDK37 verification is absent. +A read-only pre-install snapshot refused before any update dispatch. Installation and the controlled +pending-Send phone case remain unverified; beta22/code23/source `2ba0b142` is last verified installed. +The original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. See [the scoped finding](../docs/android.md#pending-send-outcome-after-remount-2026-10-08-a137). + +**Beta22 remount follow-up (2026-10-08 — bounded physical pass).** On the Pixel 7a, +retained-signer beta22/code23 (`fa894d4b`, source `2ba0b142`) keeps the exact unsent draft and +checked Ctrl through one normal locally rejected Pair screen and Back. Both original Bash panes, +server/socket, captures and desktop clients stay unchanged; the phone client reattaches under its +same SSH parent/session/TTY. No Send or producer runs. Source `2ba0b142` passes **1054/114**, offline +app compilation and CI37732792493 all-five/ten-step. In-place update preserves measured install +time and full runtime-permission blocks. Owned profile/auth/reverse/fixture cleanup is verified; +raw-input Ctrl timing, selection/IME and pending-Send checks remain unverified. The original +**10 Pass / 22 Partial / 32 Pending** ledger and installed artifact source stay unchanged. + +**Retained terminal editor (2026-10-08, A136 — source fixed; bounded fixed-phone pass).** +The Pixel 7a/beta21 loses its unsent draft and armed Ctrl after another screen covers the terminal +and Back returns. The fix keeps the full editor and Ctrl state with that live navigation entry. +Nine store and three wiring regressions pass; seven policy mutants and one wiring mutant are +caught. Beta22/code23 is published and installed; one retained-entry remount now passes. The +original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged; see +[the scoped evidence](../docs/android.md#retained-terminal-editor-2026-10-08-a136). + +**Captured Ctrl consumption (2026-10-08, A135 — source fixed).** Delayed raw-keyboard +consumption now checks the captured Ctrl revision as well as its viewer. Five policy and three +wiring regressions pass; three policy mutants and one wiring mutant are caught. Source publication +and beta22 installation pass; a physical scheduling reproduction remains pending. See +[the scoped evidence](../docs/android.md#captured-ctrl-consumption-2026-10-08-a135). + +**Lost Send reply/newer draft (2026-10-08 — bounded physical pass).** A fresh Pixel 7a/beta21 +direct-SSH case loses the original reader's successful 22-byte Enter receipt before it can return +to userspace. The exact newer 146-character draft is observed with Send disabled before loss; +it remains afterward while the app reconnects. One original-TTY witness/ACK stays unique at a +late observation more than 120 seconds later, and automatic recovery attaches a new viewer to +the original Bash/server/socket/producer with the complete sibling guard unchanged. Observed +checkout `5a3013dd`, Desktop build `0d8594a6` and installed APK/source `ef4caec2` are distinct. +Owned cleanup and original 15000-ms timeout restoration pass. Android's internal result is not +observed; network blackhole, whole-host loss, remount/Ctrl during pending Send, native TLS/history +and the full device matrix remain unverified. The original **10/22/32** ledger stays unchanged. +See [the measured receipt loss](../docs/android.md#lost-send-reply-and-newer-draft-2026-10-08). +A new real SSH/tmux second-Enter acknowledgement regression passes healthy/restored controls; +its false-success mutant is caught. Production behavior and the installed APK are unchanged. + +**Held Send reply/newer draft (2026-10-08 — bounded physical pass).** A fresh Pixel 7a/direct-SSH +case observes the exact newer 146-character draft with Send disabled before a measured +9.000280-second post-execution receipt hold ends; after release the same draft remains with Send +enabled. One original-TTY witness/ACK stays unique 168.735781 seconds later, with the original +Bash/server/producer/socket/guard retained. Observed checkout `50e7a876`, Desktop build `0d8594a6` +and installed beta21/code22 APK/source `ef4caec2` are distinct; no product or APK change occurs. +Owned phone/host cleanup completes, with original timeout 15000 ms independently confirmed after +an uncertain restore, without replay. At that checkpoint, lost reply, network blackhole, +remount/Ctrl during pending Send and native TLS/history remained unverified; the original +**10/22/32** ledger is unchanged. +See [the measured receipt hold](../docs/android.md#held-send-reply-and-newer-draft-2026-10-08). + +**Earlier nine-second Send/newer-draft follow-up (2026-10-08 — bounded partial).** A fresh `d361ce8f` +fixture uses unchanged Desktop/build `0d8594a6` and stock beta21/APK source `ef4caec2`. +One Send and a draft suffix return before the same SSH peer resumes, but the final newer-draft/ +disabled-Send XML observation misses the pause deadline; the original wrapper remains failed. +Later XML retains the exact newer draft and saved pixels show its suffix; Send is enabled and one +original-TTY witness/real ACK stays unique 125.310 seconds later. At that earlier checkpoint, the +strict timed race and lost-reply cases were unverified; the failed wrapper remains preserved. +See [the bounded follow-up](../docs/android.md#nine-second-send-and-newer-draft-follow-up-2026-10-08). + +**Silent SSH peer recovery (2026-10-08, Pixel 7a / beta21 — bounded pass).** Fresh owned checkout +`04da9b9e` uses unchanged Desktop/build `0d8594a6` and installed APK/source `ef4caec2`. Only the +admitted old SSH peer is stopped for 45.000 seconds on shared loopback; automatic recovery creates +a new viewer while it is paused, preserving the original shell, producer, tmux server/socket and +guard. The unsent draft and its saved rendered text remain; one later explicit Send executes once +and receives a real ACK. At that checkpoint, whole-host/network loss, cursor position and +lost-reply races remained unverified. Owned cleanup passes; the original **10/22/32** ledger stays +unchanged. See +[the bounded receipt](../docs/android.md#silent-ssh-peer-recovery-and-single-send-2026-10-08). + +**Native Desktop continuity (2026-10-08 — scoped verification).** A fresh private Desktop/build +`0d8594a6`, observed with the docs-only `98907171` checkout, passes two original session-host +factories, retained capture of all 200 unique numbered markers with Unicode, one normal renderer +reload and input to the same original shell before/after it. The complete sibling capture and +original daemon/Bash/producer/socket generations stay unchanged; owned retirement is verified. +Android native-history/Live-overlay acceptance, silent network loss and native-route held/lost replies remain open. +Installed beta21 and the original **10 Pass / 22 Partial / 32 Pending** ledger are unchanged. +See [the native consumer receipt](../docs/android.md#native-desktop-capture-and-renderer-reattach-2026-10-08). + +**Login-shell deadline (2026-10-08, A134 — published; original create verified).** +Signed, pushed `3f382367`/`0d8594a6` pass forced **1033 methods / 110 suites**, offline app compilation, +full TypeScript and **161 tests in nine affected Vitest files**, with five known Windows-only skips. +[Run `37702852854`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37702852854), attempt 1, passes all five jobs and ten required steps. +A fresh source-built Desktop observes one original Linux session-host create fulfill and display +its Bash prompt, with zero PTY input. V13's child cause and whether fallback fired remain unknown; +native history and its phone overlay remain pending. Installed beta21/source `ef4caec2` and the +original **10 Pass / 22 Partial / 32 Pending** ledger are unchanged. +See [the scoped source checkpoint](../docs/android.md#login-shell-probe-completion-deadline-2026-10-08-a134). + +**Direct SSH continuity (2026-10-08, Pixel 7a / beta21).** Desktop `0d8594a6` with the unchanged +stock APK/source `ef4caec2` passes controlled SSH-handler recovery, two active Home/resume cycles, +draft retention and one explicit Send to the original tmux pane. The producer/server/socket and +sibling guard remain unchanged. Phone cleanup is verified; the outer runner stop-accounting +negative is preserved. Native-history/relay/notification and wider lifecycle cases stay open. +See [the bounded continuity receipt](../docs/android.md#direct-ssh-reconnect-and-active-background-continuity-2026-10-08). + +**Beta 21 published and installed (2026-10-07 — bounded closed SSH display verified).** +Signed, pushed `ef4caec2` passes forced **1033 methods / 110 suites**, zero failures/errors/skips, +offline app compilation and full TypeScript. [Android run `37678829862`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37678829862), +attempt 1, passes all five jobs and ten required steps. Retained-signer **beta 21/code 22** is +installed on Pixel 7a, APK SHA-256 `91638d05e6192794675dee6a045f53f19cdea707187ebb3482b8ae3f10320120`. +All **46 SDK 36 artifact checks** pass; actual SDK 37 tools are unavailable. The 5.083-second +beta-20→21 update preserves first-install time and the complete notification grant/flags; +the same owned SSH profile and session rows remain visible after the update. +A fresh owned direct-SSH producer completes all 1200 numbered rows and its confirmed 24-byte +completion write in **48.23 seconds**. Actual producer progress crosses Home/resume; later phone +screens show numbered/Unicode rows, the completion marker and older tmux history after a swipe. +The crossing uses receipt/filesystem order, not exact phone-monotonic latency. The original A132 +negative remains separate, with attribution open. A133's same-page exit-0 display retains eight +Unicode rows, wraps and the final tail; Copy-sheet filtering/selection, exact OSC8/plain URL offers +and font redraw pass. A fresh same-page follow-up copies the exact final marker and one Unicode +row through the normal clipboard into the ended composer; OSC8/plain link offers match after +smaller and restored fonts. At that copy/link checkpoint, browser navigation and Share were untested; wider lifecycle/TUI checks remain unverified. A129/A130 native-history acceptance and wider lifecycle/notification checks remain separate. The original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. +The preceding docs-only `9b8bba2b` checkpoint passes forced 1033/110/app/TypeScript and +[Android run `37690764287`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37690764287), +all five jobs and ten required steps. The build and installed APK remain bound to `ef4caec2`; +subsequent documentation publication requires fresh exact-revision gates and CI receipts. +The user authorized ongoing tests without a screen lock: ordinary wake and app launch are allowed, +with no PIN input or lock/security changes. The owned QA profile/public-key line/reverse are +removed, actual timeout readback confirms the original 15000 ms, and the exact disposable host +retires with listeners closed and private authentication removed. Beta21 remains installed. The regular Desktop is not deployed +and no PR is opened. + +**Fresh native diagnostics (2026-10-08; source unchanged).** The `9b8bba2b` normal-Fedora +40-ms run records all 1200 rows and its standalone marker; original A132 attribution stays open. +Linux session-host V8/V9 pilots stay blank before input; V9's 30-second gate/all-thread observations +still do not prove original creation or attachment. A129/A130 acceptance remains pending. See +[the scoped evidence](../docs/android.md#fresh-fedora-output-and-native-desktop-readiness-2026-10-08). + +**Browser/Share pilot (2026-10-08; bounded live SSH).** Normal Copy Links Open reaches Vanadium's +exact nonce address and rendered content; Share previews only the selected marker and Back returns +to the owned terminal. Browser Back remains unverified; ordinary resume succeeds. Original +producer/guard continuity and exact owned cleanup pass; Open-focus and timeout uncertainties +are reconciled without replay. +No recipient, closed-page browser-lifecycle, wider matrix or ledger credit is claimed. + +**Earlier beta 20 publication and installation (2026-10-07, A131 — physical checks partial).** +Signed `11fbff08` passes forced **1029 methods / 108 suites**, zero failures/errors/skips, +offline app compilation and full TypeScript; [Android run `37659190980`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37659190980) +passes all five jobs and ten required steps. Retained-signer **beta 20/code 21** is installed on +Pixel 7a; the 4.875-second beta-19→20 update preserves first-install time and notification grant/flags. +A resumed direct-SSH screenshot contains later numbered rows than the earlier capture; active +production during backgrounding is unproven, so no background-delivery pass is claimed. The guard +shows only its own prompt. All 1200 numbered rows remain in native capture, but its completion marker is +absent there too ([A132](../docs/android-audit-2026-09.md#a132), attribution pending). After a separate +phone Send/exit, the terminal shows `[exited]` and an exit-0 overlay without the previously displayed +history ([A133](../docs/android-audit-2026-09.md#a133), cause pending); final-marker emission was not captured. +A131's focused **71/8** and **10 behavioral/four app source-pin mutants** remain source evidence, +not full lifecycle acceptance. Already-dispatched WebView JavaScript, A129/A130 native history, +wider lifecycle/notification cases and the original **10 Pass / 22 Partial / 32 Pending** ledger +remain outside these observations. See [the bounded receipts](../docs/android.md#beta-20-publication-and-bounded-output-checks-2026-10-07). + +**Closed SSH/tmux display follow-up (A133 — published source repair; bounded physical checks pass).** +Native raw exit traces confirm `CSI 2 J` erases the visible alternate pane before `CSI ?1049l`. +This change retains that pane only at an admitted SSH/tmux EOF, as an inert same-page display with +Copy/links; live parsing, scrolling and transport capabilities stay unchanged. **27 shipped-xterm +component cases pass in the permanent behavioral JUnit regression**, alongside **three passing +app source-pin methods** and the existing swipe suite. The first full run caught a same-view delayed +Enter regression; layout changes now preserve it while viewer retirement still cancels it. Its +27-case mutation/control/restored checks pass. Earlier **11 semantic mutants** plus one geometry +mutant and three separate app source-pin mutants remain separately recorded below. Final +1033/110/app/TypeScript gates and all-five/ten-step CI pass at `ef4caec2`; beta 21/code 22 is +installed. Bounded same-page closed-screen, Copy-sheet, exact final-marker/Unicode clipboard +paste and post-font OSC8/plain link-offer checks pass. The later normal browser/Share pilot is +bounded as above; wider lifecycle/TUI checks remain unverified. The earlier beta 20 +came from `11fbff08`, with `656e5d8a` as its later docs-only head. A132's clean native +short/long-prompt controls pass, but the +original Fedora 40-ms/phone completion-marker case remains open. The original 64-row ledger stays +**10 Pass / 22 Partial / 32 Pending**. The equivalent iOS EOF/display behavior needs adoption review +by @eneskirca; no wire change is introduced. + +**Earlier beta 19 checkpoint (2026-10-07 — bounded direct-SSH checks pass).** +Signed, pushed `5bda2c32` includes the A130 held Live-touch fix. Forced fresh offline protocol +checks pass **1015 methods / 106 suites**, zero failures/errors/skips; offline app compilation +and full TypeScript pass. [Android run `37608652625`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37608652625) +passes all five jobs and ten required steps. Retained-signer **beta 19/code 20** is installed +on the intended Pixel 7a, SHA-256 `e7271ebe1c1d65d8f6a09f7c59ab4b0042081c152c421568caabdcf6965bad42`. +The 6.18-second beta-17→19 update preserves first-install time and the notification grant/flags; +actual SDK 36 artifact checks pass and SDK 37 tools are unavailable. Public SSH identity continuity +and a fresh explicit-profile first pin pass through an owned ADB reverse tunnel. Emacs/vi copy-mode +Send, brief background/resume draft retention, neutral viewer-close/manual Reattach and controlled +SSH-handler recovery pass on the disposable Linux host; that earlier owned cleanup passes. +Saved-profile/pin reuse and manual changed-key refusal now pass too; A129 native/session-host +history, A130 held Live touch and the wider lifecycle/provider matrix remain pending. The original +**10 Pass / 22 Partial / 32 Pending** ledger is unchanged. + +**A105/A106 Pixel 7a checkpoint (2026-10-07, Desktop `e06b5547` / beta 19).** Normal Always +allow applies one original `localSettings` rule; real sibling Claude makes two Bash calls without +another hold. One original two-question request submits Cobalt (single) and Lint/Docs (multi), +with real CLI tool/Stop/final-output confirmation. Saved-profile/pin reuse and changed-key refusal +pass, and both owned fixtures and the QA profile/key/reverse retire cleanly. Rule restart-persistence, +production-pane-child launch and the wider matrix remain pending. + +The fresh API-30 emulator boots initially but remains offline +after its one provisioning reboot; its bounded reconnect/screenshot/debugger diagnostics and +owned cleanup add no app/CA/APK acceptance. See [the scoped evidence](../docs/android.md#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07). + +**Earlier native history publication (2026-10-07, A129 — physical acceptance pending).** +Signed backend `0818bbed` and host/Android/producer/CI `2d693263` are included in the signed, +pushed integration `1add0408`. They implement +`pty.attach`'s `scrollV1` capability and `pty.scrollV1` for bounded, inert native/session-host +history. A valid viewer token continues paging its immutable snapshot despite live application +mouse-mode changes; Live, actual user input, resize or an explicit new intent clears that view. +A new intent checks the actual backend mode before any wheel input. Source publication and +mandatory verification pass; actual app/WebView, physical ConPTY and phone acceptance remain +unverified. + +Keep the proof layers separate: **34 emulator tests / 3 files + 20 assertion-caught mutants**; +**343 backend tests / 37 files + 21 assertion-caught mutants**, with four existing Windows-only +skips on Linux; **13 helper/RPC control/restored tests + 18 assertion-caught mutants**; and one +fixture-cleanup test with one assertion-caught mutant. Focused Android control/restored runs pass +**99 methods / 11 suites** and the offline app check. Its **14 assertion-caught variants** are +**13 behavioral mutations + one posted-Runnable source pin**. Separate CI-reader control/restored +checks pass **11 methods** and catch **eight configuration-input deletions**; these are not +additional production mutations. No aggregate combines these layers or earlier attempts. +The root helper/RPC receipt is `a129-history-view-proof-v4-67xv3v57/mutation-result.json`, SHA-256 +`fe62c4f299afa9a7254ddb8aaf8e052386e71f7742917565c90363a8fb025583`. +At exact `1add0408`, forced fresh offline protocol checks pass **1012 methods / 105 suites** +with zero failures/errors/skips; offline app compilation, **568 affected Vitest tests / 52 files** +with exactly four existing Windows process-tree skips, and full TypeScript pass. +[Android run `37602046133`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37602046133), +attempt 1, passes all five jobs and ten required steps. Private +`a129-publication-biru7yjl/gates.json`, `ci-result.json` and `final-report.json` bind this source. +The retained-signer **beta 18/code 19** APK is ready and **not installed on the intended phone**, SHA-256 +`19d55ee177335064b1edb4874b6d27216862d16a7d30ebe476875c566cd263e0`. Actual SDK 36 artifact +checks pass; SDK 37 artifact verification is unavailable. These are source/component/recorder +and browser-harness boundaries, not full Desktop/WebView or phone acceptance. The regular +Desktop is not deployed and no PR is opened. +The separate published A128 checkpoint `944223ef` has **984 protocol methods / 102 suites**, +**360 affected Vitest tests / 26 files** and all five jobs/ten required steps green in +[Android run `37594743169`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37594743169). +Its private `a128-publication-1vq7hcgc/gates.json` and `ci-result.json` retain that exact source +binding. At that earlier checkpoint, installed beta 17/code 18 remained from `a79375c3`; no new physical acceptance or change +to the original **10 Pass / 22 Partial / 32 Pending** ledger is claimed. + +**Held Live-button published follow-up (2026-10-07, A130 — app acceptance pending).** +Signed `9da36320`, included in published `5bda2c32`, stops old momentum and the native pending-scroll +queue on touch-down, preserving history and its display epoch until click. Three new LiveTouch +JVM methods pass within **48 methods / 7 suites** in control and restored runs, with zero +failures/errors/skips. **Three semantic mutants** (old handler, premature history close, parent +gesture) fail assertions. Exact-head final gates/CI pass and beta 19/code 20 is installed; +actual WebView/physical acceptance remains pending. The `1add0408` failure remains historical. + +**Composed Send backend follow-up (2026-10-07, A128).** Current source also supports the +attached direct native Windows PTY and a session host that negotiates `composed-input-v1`. +The host owns one-use tickets scoped to its live session generation and the original subscriber +socket; paste and Enter remain separate writes at least 150 ms apart. A lost receipt after input +may have been sent stays uncertain and never replays. Older live hosts refuse without being +restarted. The public Android `pty.submitComposed` request/result is unchanged; its actual +backend producers are tested through the relay and Kotlin client. Windows ConPTY and phone +acceptance of these additional backends remain unverified. At that earlier checkpoint, installed beta 17 still came +from `a79375c3`; that host-side follow-up did not represent a new installed APK. + +**Beta 17 Pixel 7a follow-up (2026-10-06, A127/A128).** Retained-signer beta 17/code 18 +from `a79375c3` is installed on the intended Pixel 7a (Android 17/API 37, +Vanadium `154.0.8037.126.0`) against a fresh disposable Desktop from the same source. +The 5.48-second same-signer update preserves the first-install time and saved temporary SSH +profile; Sessions reopens without editing its pin or readmitting the phone key. One emacs +copy-mode Send submits exactly once to the original producer, exits copy mode and leaves the +sibling guard unchanged. The A127 viewer-only exit now shows a neutral connection-close notice, +disables Send and retains the draft; manual Reattach preserves the same producer and complete +same-grid history, and the retained draft submits once. These are bounded repaired-device cases; +vi copy-mode recovery/Send also preserves the pane and full same-grid history, with the draft +retained and Send disabled during the outage, then one native submission after recovery. One +Ctrl+C action reaches the owned raw TTY consumer as exactly `03`, with no extra byte in a bounded +0.8-second window. Actual phone-managed shell creation and exact owned SSH End pass too: +the producer/pane/viewers end while the sibling guard and expected canvas node remain. +Only the temporary profile/public-key admission are retired; the nonce-validated host stop +removes generated private authentication and closes both owned listeners. Nine bounded cases +and 303 raw-artifact hashes are recorded in the frozen report; ordinary native Quit is unverified. + +At exact merged `a79375c3`, full forced offline protocol checks pass **982 methods / 102 suites**, +zero failures/errors/skips; offline app compilation, **187 affected Vitest tests / 11 files** +and full TypeScript pass. [Android run `37523703627`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37523703627) +passes all five jobs and ten required critical steps. The APK and installed public-code pull +both hash to `c51433d4…`; actual retained-signer verification uses SDK 36, with SDK 37 tools +unavailable. A127 is source-fixed in `0d50075a`; A128 in `7e7d2c04`, with beta CI registration +`5484ab9f` and **30 assertion-caught source/configuration mutations**. Older beta-16 failures +remain historical. This does not complete the 64-item checklist or the original Pixel 10 Pro +ledger, which stays **10 Pass / 22 Partial / 32 Pending**. See [the bounded beta-17 receipt](../docs/android.md#pixel-7a-beta-17-upgrade-and-composed-send-2026-10-06). + +**Composed Send source checkpoint (2026-10-06, A128).** `7e7d2c04` awaits submission through +the captured viewer's input actor, exits history on that exact pane and separates paste from a +freshly guarded Enter by 150 ms. Ctrl remains one raw byte without Enter. Only one delivered +receipt for the same current viewer clears an unchanged draft; edited drafts, rearmed Ctrl, +refusals and uncertainty are preserved, with no replay or legacy raw/`node.sendKeys` fallback. +Direct Unix SSH and current local-tmux relay are supported; legacy, native Windows/session-host +and SSH-project relay routes explicitly refuse. A positive receipt means pane/PTY submission, +not command execution. **170 distinct Kotlin methods / 8 suites**, **187 Vitest tests / 11 +files**, full TypeScript and offline app compilation pass; **26 source mutations fail assertions**. +`5484ab9f` adds four private-beta CI registrations: nine actual Gradle configuration methods +pass and four deletion mutations fail assertions, with passing control/restored checks +(**30 assertion-caught mutations** total). The subsequent merged `a79375c3` passes 982/102 protocol and app +and affected Desktop gates plus all five CI jobs; retained-signer beta 17/code 18 is installed. +Bounded emacs/vi Send, vi transport recovery, raw Ctrl and A127 viewer-close/manual-Reattach +phone cases, actual managed shell creation, exact owned SSH End and cleanup pass. Nine bounded +cases/303 raw hashes do not complete the original checklist; broader physical/provider acceptance +remains pending. Installed APK/runtime provenance remains `a79375c3`; later docs-only publication +checks do not represent another installed build. See [the source proof](../docs/android.md#composed-send-source-checkpoint-2026-10-06-a128) +and [the bounded beta-17 checkpoint](../docs/android.md#pixel-7a-beta-17-upgrade-and-composed-send-2026-10-06). +The additive `pty.submitComposed` contract includes the Android client and actual interop fixture +in the same source commit; **iOS @eneskirca** needs the same action, receipt and retention rules. + +**Desktop SSH reconnect checkpoint (2026-10-06, A125/A126).** A125 is source-fixed in `415dae9b`; A126 is committed in `5b7286b3`. +Eight actual Linux Desktop **Canvas/Modal × soft/hard wraps × inner none/SGR mouse** cases +pass at `5b7286b3`. Each loses one birth-pinned product ControlMaster, creates a new master/ +selected local viewer and retains its remote pane/server/producer/cwd and complete same-grid history before, +during, after and at final observation. Both trusted actual textarea `g` inputs are acknowledged +on the old/new PTY: **16 ACKs**, **zero unexpected bytes**, one producer paint per case and +**16 complete URL activations**. Native hover/leave and exact owned cleanup pass. Modal cases +use `80x24`; Canvas cases use `49x48`, with each history comparison bound to its own grid/case. +**294 unique affected tests / 17 files** and full incremental TypeScript pass. **15 isolated +mutants (six A125 + nine A126)** are caught by assertions; each fix's control/restored runs pass. +Forced offline checks at `5b7286b3` execute **950 tests / 98 suites**, zero failures/errors/skips, +and both protocol/app Kotlin compile tasks. These are disposable QA hosts; no new APK or regular +Desktop installation is included. Publication requires fresh offline gates and all five Android +CI jobs for the exact published HEAD; its private bundle and final report carry that run's result. +A separate Linux two-host soft/none case passes at `59e4c93e`: one quiet park/adopt cycle, +inactive B global-card warm reconnect while A stays active and unchanged, **3 native ACKs / 2 +complete URL activations**, retained same-grid history and exact owned cleanup. See [the bounded two-host follow-up](../docs/android.md#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06). +The earlier negative/unclassified controls remain historical. Phone release/backend checks still +need their own acceptance; wider parking, Server/cross-window, outage/platform and GPU coverage +remain separate. +Unsupported private shapes preserve input with a warning but may duplicate replies. Android +wire/file/iOS adoption is unchanged. The original Pixel 10 Pro ledger stays paused at +**10 Pass / 22 Partial / 32 Pending**. + +**Fresh-phone checkpoint (2026-10-05, A124).** A separate Pixel 7a has a fresh, +hash-verified, non-debuggable beta 16/code 17 installation (Android 16 / API 36, +Vanadium WebView `145.0.7632.120.0`). Manual explicit-profile, high-port SSH and the existing +terminal's input/cwd/Unicode history and bounded held-drag/coast/new-touch-stop checks pass. +The original **New session → Start** attempt kept default Claude selected and failed shared +shell validation on host `15dd1341`. A124 (`651f46da`) fixes absolute executable resolution. +**The repaired single managed-shell case passes physically on Desktop `40731381`:** actual shell +radio selection, canvas registration, input exactly once, viewer reopen and saved SSH reconnect +after an Android process restart all preserve the same pane and the other original terminal. +Offscreen retained-history Find, selected-line clipboard paste and Copy-sheet URL clipboard +roundtrip also pass. Both disposable QA host profiles were removed through the app; beta 16 and +the phone identity remain. The repaired host stopped cleanly with generated authentication removed +and listeners closed. The original Pixel 10 Pro beta-10 checklist stays paused at +**10 Pass / 22 Partial / 32 Pending**. Fresh-install/single-case results do not verify its update, +live agents, managed End, power loss, QR/relay/cellular routes or the full device matrix. +Dated receipts below remain historical. + +At checkpoint `40731381`, offline protocol tests pass **949 methods / 98 suites**, with zero +failures, errors or skips; **38 affected Vitest tests** pass with five platform skips, full +TypeScript passes, and all three isolated guard mutants fail their expected assertions. +The frozen full Desktop build passes in **29.3 seconds**. The first push has all five Android jobs +green in [run `37375870878`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37375870878), +verified against exact head `40731381c646ccd3425af597b116b5371daf0a63`. Later pushes need their own gates and CI. + +**Native custom-wheel checkpoint (2026-10-06, A86).** A real SSH/tmux regression now verifies +custom copy-mode bindings in both emacs and vi modes, distance across 20-notch chunks and +FIFO-sensitive reversals. All eight settled native positions pass; three deliberately broken +transport variants fail the expected distance assertion. The later Pixel 7a follow-up adds bounded +custom-binding and lifecycle cases; wider gesture/reversal, lifecycle and FPS coverage remains +open. See [the native checks](../docs/android.md#native-custom-wheel-checks-2026-10-06) and the physical receipt above. + +**Current source checkpoint (2026-10-05, A100–A124).** The branch adds retained terminal history search, +trusted project env/shell and per-agent launch policy, offscreen Sleeping wake, remembered hook +rules and complete held Claude questions. Direct SSH supports Source Control, selected-profile +Board writes and Desktop wake/refresh/rename. New session can now ask a current Desktop/Server +with an enabled Linux/macOS tmux backend to create and register a managed shell or agent in an +open local folder project (A111); the host resolves its command, account, environment and hooks. +Older hosts retain the relay New flow. The actual producer fixture also exposed and fixed Android CI/local incremental coverage for `src/session-host/**` (A112). Current source also adds truthful legacy revoke outcomes (A113), a dictation language picker (A114), exact connection/record retirement (A115) and a saved LAN/VPN adapter choice (A116). Managed canvas End still requires the relay; +closing the SSH viewer leaves the session running. This prepares the upstream Android contribution; no PR +has been opened. Beta 16/code 17 was prepared from `2723845e`, passed the local release checks and is now installed on the separate Pixel 7a. It includes A118's eligible legacy relay identity proof, an explicit SSH profile folder (A119) and one-time password enrollment with fingerprint confirmation and retained-key verification (A120). Current host source also fixes direct-SSH coattachment (A13) and protects relative private HostKey Include reads (A117). +A120 also passes controlled native OpenSSH enrollment, cancellation/deadline and synthetic installed-Fedora-PAM checks; see the later verification receipts. +Current Desktop source also fixes focused-terminal native Quit interception (A122, `9613cec5`); all four Linux native focused-terminal cases pass. +Later checks verify eight shipped-tmux and eight actual SSH-project cases. Separate GPU recovery +pilots exposed and verified A123 restored-context cleanup (`db83fb7d`). A later genuine Server +pilot verifies the same retirement path at `fa259f6e`; separate derived Desktop cell comparisons +use the existing `db83fb7d` captures. A single Desktop Canvas SSH reconnect pilot also passes +at `fa259f6e`; see the final follow-up for their limits. +The original Pixel 10 Pro remains paused on beta 10/code 11 with +**10 Pass / 22 Partial / 32 Pending**. Fresh Pixel 7a results and the A124 failure are recorded +separately above; the repaired single managed-shell case passes, while live-provider and broader lifecycle/outage checks remain unverified. Immediate FCM +and fresh-different-desktop relay recovery (A25/A93) still need the maintainers' hosted backend +source; the user has no backend checkout. Existing missing canvas sessions remain attach-only +over SSH. See the audit and Known gaps for unsupported backends and remaining verification. + +**Historical prepared private beta 14 (2026-10-04).** `0.1.0-beta.14` / code `15`, clean built source +`facbbd0384bb5b0ee91656f364793a12f8f41eb1`, adds A113 truthful revoke outcomes, A114 dictation language, A115 exact host/session +retirement and A116 saved pairing/LAN adapter choice. Full offline protocol checks pass +**890 tests / 92 suites**, with zero failures, errors or skips. +Offline app compilation, **1628 affected Vitest tests / 71 files** and full +TypeScript checking pass. Only the ten existing Windows/macOS runtime cases are skipped on Linux. +The minified offline release build passes in **47.96 seconds** with the retained private +signer and R8/runtime keeps. Independent SDK 36/37 signature, nondebuggable manifest, payload/ +provenance and 16-KB ZIP/native checks pass. + +The private archive records **158 assertion-caught isolated mutation variants**, +with passing controls/restored runs and each receipt's source bindings. One additional redundant +retired-guard variant survives equivalently and is recorded separately, never counted as caught. +Historical/intermediate bindings stay separate; full merged gates verify the release source. +Actual local HTTP and producer→Android tests use synthetic OS/key facts; HostStore tests use +in-memory Android interfaces. Native wiring source guards and cached compilation do not prove +physical recognition, VPN reachability, relay revocation or replacement behavior. Earlier beta +proof remains historical. APK SHA-256: `3c75912a5b7bb561fa9a0720be5cb11be75e1672f98379e4745871bf2729f8f6`. Artifact: +`.nodeterm/android-beta-14/nodeterm-android-0.1.0-beta.14.apk`; private proof: +`.nodeterm/android-beta-build-14/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone testing is paused; the ledger stays **10 Pass / 22 Partial / +32 Pending**. No PR opened; A68 stays deferred. Each push requires its own exact-head green +Android workflow. At this APK source checkpoint A13 direct-SSH Desktop detachment, full legacy +identity association and A25/A93 hosted backend dependencies remained open. The later A13 host +follow-up below closes the coattachment source gap; its physical checks remain pending. + +**Historical prepared private beta 13 (2026-10-04).** `0.1.0-beta.13` / code `14`, clean built source +`ba64f6289c0f552b85bfc17f99aee879aaf45975`, includes A111 managed SSH New and A112 producer-input coverage, +along with the beta-12 changes. Full offline protocol checks pass **845 tests / 87 suites**, +with zero failures, errors or skips. Offline app compilation, **1433 affected Vitest tests / +60 files** and full TypeScript checking pass; the ten existing Windows/macOS runtime cases +are explicitly skipped on Linux. The minified offline release build passes in **47.77 +seconds** with the retained private signer and R8/runtime keeps. Independent SDK 36/37 +signature, nondebuggable manifest, payload/provenance and 16-KB ZIP/native checks pass. +The new managed-creation and input-coverage regressions catch **148 isolated behavioral mutation +variants**, with passing controls/restored runs and per-receipt source hashes. Receipts distinguish +assertion failures from the named runtime contract failure; earlier beta-12 proof remains separate. +An isolated native node-pty/Electron/tmux fixture verifies host cwd/account/hooks/env/policy, +one launch submission and uncertainty fences. It uses a fixture CLI; real Claude startup, +macOS native creation and physical power-loss durability are unverified. +APK SHA-256: `faf9e508fa82355f640512ec1861ba886d0e48b8aef3e724edc6435c9f8c134b`. Artifact: +`.nodeterm/android-beta-13/nodeterm-android-0.1.0-beta.13.apk`; build and mutation proof: +`.nodeterm/android-beta-build-13/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone testing is paused; **10 Pass / 22 Partial / 32 Pending** +remains the device ledger. Managed New and the other new source flows require physical checks. +Each push requires its own exact-head green Android workflow; no PR opened, and A68 stays deferred. + +**New work after beta 10 (2026-10-04):** the branch adds Sessions search by name, agent and folder; +live refresh on All computers while visible; and captured-output Copy-sheet search, +with highlighted matches and Previous/Next. The computer now discovers SSH host-key anchors through +recursive configuration Includes. Beta 11/code 12 is prepared locally; the Pixel still runs +beta 10/code 11. Phone checks remain paused, so none of these additions has a physical pass. + +**Historical prepared private beta 12 (2026-10-04).** `0.1.0-beta.12` / code `13`, clean built source +`b2e41255d8b2cf7bb342b78b7f686b8b0191879e`, includes A100–A110 and the earlier beta-11 additions. Full offline protocol +checks pass **815 tests / 83 suites**, with zero failures, errors or skips. +Offline app compilation, **1314 affected Vitest tests / 53 files** and full TypeScript +checking pass. The ten existing Windows/macOS runtime cases are explicitly skipped on this Linux +host; no additional skip is accepted. The minified offline release build passes in +**52.47 seconds**, with retained private signer, R8/runtime keeps and source/hash provenance. +Independent SDK 36/37 signature, nondebuggable manifest, payload and 16-KB ZIP/native checks pass. +The private support archive retains **236 isolated mutation variants** with passing controls and +restored runs: 228 assertion cases, six named executable-contract failures and two bounded timeouts. +Historical source hashes remain attached to their own receipts; this count does not claim that +all earlier worktrees were identical to the final merged source. Full merged release gates pass. +APK SHA-256: `d5a350a1f7c6f05f1ab476f25cbe58441eb666d61d1a4b62610a3faa2e058e1f`. Artifact: +`.nodeterm/android-beta-12/nodeterm-android-0.1.0-beta.12.apk`; build and mutation proof: +`.nodeterm/android-beta-build-12/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone checks are paused, with **10 Pass / 22 Partial / 32 Pending**; +live Claude rule/question application and the new physical feature matrix remain unverified. +Each requested push still needs its own exact-head green Android workflow. No PR opened; A68 +remains deferred until a PR is requested. + +**Historical prepared private beta 11 (2026-10-04).** `0.1.0-beta.11` / code `12`, clean built source +`314105a435d18e719f53d49bb292d0f3e965ad4a`, includes all four additions (`A96`–`A99`) and the +A95 desktop Board fix. Full offline protocol checks pass **730 tests / 70 suites**, with zero +failures, errors or skips; the offline app compile, **297 affected Vitest tests / 17 files** and +full TypeScript checking pass. The four feature regressions catch **81 isolated mutants**. +The actual offline minified release build passes in **51.78 seconds**, retains the private signer, +and passes the R8/runtime keep and packaging checks. Independent SDK 36/37 signature, +nondebuggable-manifest, payload/provenance and 16-KB ZIP/native checks also pass. The existing +New-session-note layout check passes its control/restored run and catches four additional mutants. APK SHA-256: +`ae052a8a02567c42576012309c5de059827d8b9ff28a9972673af0440d977341`. Artifact: +`.nodeterm/android-beta-11/nodeterm-android-0.1.0-beta.11.apk`; build/regression proof: +`.nodeterm/android-beta-build-11/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed phone installation. Phone checks and final fixture/rotation cleanup stay paused; +10 Pass / 22 Partial / 32 Pending remains the device ledger. These source/build checks do not +verify the new touch/keyboard, multi-host freshness or included-key pairing flows on the Pixel. + +> **Status (2026-10-04): beta 10/code 11 is installed; paired update passes.** Focused +> plain-SSH creation/history/reconnect and exact End pass on the Pixel (item 32 Partial). +> Fresh-desktop pairing received no relay credential after an observed backend refusal (A93); +> the empty-host loading correction (A94) is delivered and its full focused empty-host flow passes. CI builds the debug APK. The +> private minified beta is installed on the Pixel 10 Pro (Android 17 / API 37); manual SSH lists real +> projects after the update. Historical proof retained through beta 6 covers basic terminal input, +> the code-3 viewport/history correction (`A85`), user-confirmed terminal access with Wi-Fi off over +> mobile-data WireGuard, real SSH background/detach recovery and Inbox/back-stack restoration. +> Encrypted paste pairing and live hosted relay browsing also have historical beta-6 proof. QR pairing, cellular relay, +> actual outage/answer behavior and the full device pass remain unverified. A95's desktop Board fix passes actual phone-to-mounted-desktop verification (item 36). The plan and what is still open are in +> [`docs/android-handover.md`](../docs/android-handover.md); the findings are in +> [`docs/android-audit-2026-09.md`](../docs/android-audit-2026-09.md). + +**Historical beta-10 paired update (2026-10-04):** `0.1.0-beta.10` / code `11`, +clean snapshot `e3ce041c752bdef11b378cf301c719a6e62f70ab`, is installed on the intended Pixel with the +unchanged private signer. The actual release build passes in 52.4 seconds with all R8 keeps; +independent SDK 36/37 signature, 16-KB alignment, payload and provenance review passes. +APK SHA-256: `04ace0ea01540bb477fcf823395a299fa211cfef3745aa28674099038ceb3693`. The 10.14-second +same-signer update preserves install identity, notification grant and the owned SSH second +pane. On beta 9, genuine desktop-issued code text re-pairs the complete original owned desktop +profile and receives relay credentials; safely anchored external SSH refuses, then Automatic +falls back to the actual relay. After updating, that saved +pairing reconnects without re-pairing; the own Terminal1 opens and input reaches its real scoped +PTY. **Items 1/36/51 pass; items 35/49 remain Partial, giving current 10 Pass / 22 Partial / 32 Pending.** Debug-key update/migration +variants remain conditional SKIP on this working private installation. The same owned production +desktop profile now runs repaired build `ec12ea9a`; the original failure was on `071735d6`. Local gates pass 689 protocol tests /67 suites with zero +failures/errors/skips (86.08 seconds wall time including a new daemon), offline app compile +(6.78 seconds), 54 affected Vitest tests and full TypeScript checking. The exact built source `e3ce041c` has all five CI jobs green in [run `37194375778`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37194375778). Each later documentation push needs its own green workflow. Private artifact/proof: +`.nodeterm/android-beta-10/` and `.nodeterm/android-beta-build-10/`; the canonical paired-update receipt is `.nodeterm/android-beta-build-10/paired-update-result.json`. Broader relay/managed, +cellular and the full device checklist remain open; installed A91/A94 full focused empty-host flow passes. + +**Relay creation, Rename and exact End verified; Refresh dispatch observed (2026-10-04).** On +beta 10, Sessions → New session → Terminal (shell) → Start creates and opens one actual +canvas-registered shell in the exact private project folder. Native input and pwd reach its +scoped bash pane. Rename updates the mounted desktop title without reload. Sessions Refresh +rediscovers both owned rows; the actual owned session-menu Refresh emits exactly one node-refresh +IPC, preserving pane identity and page time origin. Its repaint effect remains unverified. +Exact native End then removes only the adopted node/pane/PID birth; the original producer +terminal remains unchanged. The mounted Board also removes that card without reload. +Item 32's relay plain-shell variant passes; managed-account/cellular and other required variants +remain pending. Item 35 stays Partial for missing Wake and Refresh repaint evidence; item 36 +passes actual repaired-desktop move/remove/re-add/create-label checks (A95). Earlier first-round rotation restoration is historical; final restoration remains pending. The tally is 10 Pass / 22 Partial / 32 Pending; no full release/device pass +or final fixture/service cleanup is claimed. + +**Settings visible flow passes; item 49 remains Partial (A44, 2026-10-04).** On installed beta 10, +a temporary phone name and safe HTTPS loopback API persist after actual system Back and reopen. +An invalid HTTP address shows its field error after keyboard dismissal and a visually reviewed rejection toast; reopening +retains the prior valid HTTPS value. Reset to default, system Back and reopen restores the built-in +API; the original phone name/API are then restored and verified. Unedited leave/reopen preserves +the visible values. Internal preference-key absence and absence of writes were not physically +inspected on this nondebuggable app; existing `SettingsLeaveTest` behavior/source guards cover +those rules. The strict device ledger therefore marks item 49 Partial, giving 10 Pass / +22 Partial / 32 Pending. Native proof includes `beta10-settings-phase1-result.json`, +`beta10-settings-phase2-result.json` and `beta10-settings-toast-visual-review.json` in the Oct4 +checklist directory. + +**Final phone cleanup and background watch remain pending (2026-10-04).** The intended Pixel's +wireless ADB endpoint is no longer reachable; its current endpoint has been requested. The guard +refused before the final phone Forget and portrait-lock restoration, so neither action is claimed. +Earlier rotation restoration belongs to the first test round; current final restoration is pending. +The isolated empty SSH service is stopped. Phone name/API and system night mode `yes` restoration +are verified. No natural-periodic background Done watch has started and no result is claimed. +The remaining phone cleanup and notification checks can continue when the correct Pixel returns. + +**Phone system-theme compatibility passes (item 51, 2026-10-04).** On the installed beta 10 +Pixel, Computers and Settings native screens remain readable under system dark and light modes, +including status/navigation bars; four captured PNGs have a matching visual-review receipt. +The app retains its fixed dark palette in both modes; an automatic light app palette is not claimed. +Original system night mode `yes` is restored and verified. Tablet/foldable is conditional SKIP +because neither is available. Proof: `beta10-system-theme-result.json`, +`beta10-system-theme-restored.json` and `beta10-system-theme-visual-review.json` in the Oct4 checklist +directory. Item 51 passes, giving the current 10 Pass / 22 Partial / 32 Pending ledger. + +**A95 is fixed and verified on the rebuilt production desktop (2026-10-04).** The held +`071735d6` build reproduced stale mounted Board moves/labels. The same owned paired profile now +runs the repaired `ec12ea9a` production build; Android remains installed beta10/code11 (`e3ce041c`), +with no APK change for this desktop fix. Actual phone Move to Ungrouped, label removal, re-addition +and creation/application of a second blue label update both persisted state and the mounted +desktop Board without a reload. Baseline and every resulting DOM snapshot share page time origin +`1791111312961.1` and match persisted state. This completes item 36. Nine affected Vitest suites +(133 tests), full and strict acceptance TypeScript checks, and four isolated mutants also pass. +The ledger is 10 Pass / 22 Partial / 32 Pending. This verifies the owned production fixture; +no deployment to the user's regular desktop or full release/device pass is claimed. + +**Desktop continuation (`A92`, 2026-10-04):** the shared Desktop/Server renderer now keeps the +hovered row inside its capped wrapped-link paragraph. The focused 24-test Vitest file, all 54 +affected link/dialect tests, full TypeScript check and three isolated mutants pass. Fixed in +`127b6b28`; all eight native Linux Desktop UI cases now pass at `01b4a2f5` with A121's leave fix. +Separate Linux Server checks pass at `dcdf664a`. The later GPU-enabled eight-case matrix passes +(Canvas4 hardware / Modal4 DOM). Those earlier runs did not verify GPU recovery; later individual +loss/restore pilots are recorded in the final follow-up. macOS remains unverified. Android already +fixed this in `A32`; no APK or host +contract change, iOS adoption or new phone result follows. This desktop fix alone does not change the installed APK or checklist tally. + +**Fresh-desktop relay pairing refusal (A93, open; 2026-10-04).** The same Pixel identity +paired with a fresh isolated production desktop whose remote access was on, but the saved +pairing had no relay credential. A bounded retry using the exact unchanged production request body +returned HTTP 403 reauth_required. Forgetting the old fixture removed its phone-side relay +token; the new desktop has a different identity. The refusal is measured. Original +same-desktop recovery succeeds after ordinary restart of the complete original owned fixture +profile: unchanged host/device identity, genuine beta-9 re-pairing and actual relay credentials. +No backend repository was reviewed, phone identity reset or user credentials copied. This +measures one legitimate recovery case; fresh-different-desktop refusal remains open. +**iOS follow-up for @eneskirca:** verify prior-token lookup and recovery messaging for the same +persistent phone identity after Forget; no new wire field is introduced here. + +**Empty-host loading follow-up (A94, fixed in 3c217cba; delivered in beta 10).** On beta 9, +the otherwise-empty SSH fixture completes its listing and shows the error banner, Over SSH and +New terminal, but still says “Loading sessions…”. The initial empty snapshot's zero fetch +timestamp causes that label. The correction stamps the completed empty answer; five actual +Kotlin regression methods and eight isolated mutants pass. Full Gradle checks and beta-10 +delivery and installed full focused empty-host End/recreate/open/End verification pass. No host contract changed. + +**Installed A91/A94 focused empty-host cycle passes (2026-10-04).** On beta10/code11, +native End of the otherwise-empty fixture's sole owned Home shell removes its row/group and +shows “No sessions on this computer yet.” with no Loading label; Over SSH and the data-not-found +banner remain. New terminal → Home → Create opens a new actual bash pane in Home. Back to +Sessions and exact native End removes that new UUID and restores the same completed empty +screen. All three isolated host sockets are empty. Proof: +`.nodeterm/android-beta-build-9/checklist-20261004/beta10-a94-full-cycle-result.json`, +`beta10-a94-recreated-pane.json` and the matching empty-screen UI receipts. This passes the +focused A91/A94 empty-host flow; item 32 remains Partial because managed and cellular +creation remain open; the relay plain-shell variant passes. Total 10 Pass / 22 Partial / 32 Pending. Final fixture/service cleanup is +not yet claimed. + +**Beta 6 verifies continuous dragging, coast, Esc and new-touch stopping (`A86`, `A89`).** SSH +reopened on the intended Pixel with its retained key/pin at 56×48. `c4b1f6cf` targets the stable +screen behind changing text, fixing beta 5's detached touch target. Code 7 passes 658 protocol tests and +release/R8/signing checks. A held touch stops coast at a stable 56×25 keyboard-open viewport; +earlier resizing tap checks were inconclusive. The user confirms normal dragging and coasting +both work now. Remaining lifecycle checks, FPS and custom bindings remain open. + +**Requirement-audited real Pixel QA:** complete checklist passes are **1, 18, 19, 21, 22, 24, 36, 38, 39 and 51**: +SSH software input/key chips/font/rotation survival, Unicode rendering, +large/invalid OSC52 handling, all three keyboard-focus states, and background-process tab/back-stack +restoration and the shipped managed-hook Approve/Deny/expiry lifecycle. The hook producer is +synthetic; no live Claude CLI/account or requested Bash execution was involved. Seventeen sending +chips plus four app-mode arrows and font/rotation complete item 18 on the existing proof; +wrapped/OSC8 links, Copy-sheet Unicode/Share and real SSH recovery have partial evidence. Landscape with IME open +was 129×1; usable-height and larger-client Fit checks remain open. The [64-row result table](../docs/android.md#what-is-verified-and-how) +records ten Pass, 22 Partial and 32 Pending items with conditional SKIP variants. Item 1 adds +paired-update proof; the focused A90 proof leaves item 32 Partial. The earlier 7/20/37 checkpoint used beta6/code7. Encrypted PairingService JSON paste and **Only through the relay** +browse succeed against production desktop source `58a202be`, with the actual hosted API/relay; +the current `/v1/relay/join` shape is verified beyond interop tests. Remote access was already +enabled, so this pairing approves the phone without a first SAS prompt. The desktop uses an +isolated home and real SecretService encrypted credential storage; no user credentials were copied. +The phone also attaches and sends echo input through the relay to an owned real PTY, created by +production preload/Canvas events rather than phone New session. At that stage checks passed all 658 +protocol tests in 63 suites (54 seconds) and offline app `compileKotlin` (5 seconds). +Mounted relay single-select answers and multi-select Open-session/input reach the synthetic +application and its actual PostToolUse/mirror. A one-tap answer from copy mode also reaches the +application, leaves copy mode and resolves the question. Item 41 stays Partial: offscreen/released, +direct-SSH and target-guard variants lack device proof. No live Claude CLI/account was used. +QR/scanner, cellular relay, SAS denial/revoke, remaining node actions, remaining question routes, +notifications +remain open. Recorded prior all-green branch CI is `19da35a2`, all five jobs in [run `37140762345`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37140762345). Installed beta 9 is separately bound to `4d33a5b5`, whose source CI failed the documentation checklist mapping. Each later push still requires its own green workflow. +Only disposable fixture resources were removed and the phone returned to regular Sessions; +cleanup does not establish the full revoke matrix. **Final beta-6/code-7 cellular check:** with +WireGuard enabled and Wi-Fi off, the user confirms "Connects and scrolls smoothly" in their usual +manual-SSH terminal, confirming connection and smooth scrolling. Cellular hosted relay remains untested; +item 20 stays Partial and the full 64-item pass remains open. No runtime change or phone command +was needed. The 50/1-second protocol/offline app tasks at that stage are historical; current A90 +checks are recorded below. Each subsequent push still requires green CI. + +The user resumed remaining Pixel release checks on 2026-10-04 after the hike. The requirement review adds +item 18's Pass without new phone work; viewport/Fit remains item 23's open scope. Item 1 requires +desktop-issued pairing/relay credentials through a higher-code update, using JSON or QR. Full +release readiness, hosted cellular relay and live-Claude checks remain unverified. + +Historical private `0.1.0-beta.7` / code `8` was **prepared, never installed**, from +`b53610deb3843b59fa6a1bed5bdc5f36da0f5146`, using the retained signer. Local AGP built in +47 seconds; R8 and packaging passed. APK SHA-256: +`5141c6484b422b236a98213731076be621c4d14a55f47c79bfd989fb23609e6a`. +Ignored proof/artifacts are in `.nodeterm/android-beta-build-7/` and `.nodeterm/android-beta-7/`. +Beta 8 below replaced it and is now installed; beta-7 preparation added no runtime +fix or device pass. + +**Plain SSH terminals (`A90`) are implemented and host-verified.** On the Linux SSH host, use +**Sessions → New terminal → Home folder / project folder / custom absolute folder → Create**. +The shell appears under **Phone terminals**, persists when the viewer/app closes, and can be +reopened or stopped with that row's **End session…**. These are independent shells, with no +automatic desktop-canvas registration or managed-agent identity. Protocol `bcc92367` and UI/model +`b88d1415` preserve existing cold-agent SSH refusal and relay canvas creation. All **684 protocol +tests in 66 suites pass with zero failures/errors/skips** (48 seconds), offline app `compileKotlin` +passes (1 second), and **32** protocol/helper/native-wiring mutations are caught (11/12/9). +Desktop and Server Edition retain their own sockets and canvas behavior; existing RPC, projects +blob, pairing, mirror and SSH-visible file contracts are unchanged. **iOS follow-up for @eneskirca:** +adopt the isolated phone socket and atomic creation marker/session metadata for independent shells. + +**Focused physical A90 checks pass on the intended Pixel.** Beta8/code9 creates Home, discovered +project and custom-folder shells (spaces/apostrophe/dollar), with verified cwd/input/pre-attach +history, continuous dragging/coast/Esc, rapid double-Create without duplicates and visible +missing-folder refusal followed by a corrected retry. Force-stop/restart retains three PIDs and +history; beta 9/code 10 update, reconnect and same-pane input also pass. Native UI End of custom, +project then Home removes only each selected UUID, retains sibling fingerprints/PIDs and clears +the final Phone terminals group. Eleven desktop sessions/PIDs and nine project/workspace hashes +are unchanged; owned shells/folders/UI dumps are removed and the phone returns to regular Sessions. +Proof: `.nodeterm/android-beta-build-8/a90-pixel-check-20261003/final-focused-results.json` and +`beta9-exact-ui-end.json`. **Item 32 remains Partial; this Oct3 checkpoint was 7 Pass /21 Partial /36 Pending.** +Relay canvas/managed and cellular creation remain pending. The separate A91 otherwise-empty-host +variant passes on beta 9 on 2026-10-04. Broader release checks resumed on 2026-10-04; new outcomes are recorded separately. + +**Empty-host refresh fix (`A91`) is delivered in beta 9/code 10.** Ending the last phone shell +on an otherwise empty SSH host returns an authoritative "nothing found" answer. Beta 8 +kept its stale cached row; `4d33a5b5` now clears that listing while preserving the error and +connected SSH route. Other failed refreshes keep the last listing, and cancellation propagates. +The full real Gradle suite passes **688 tests / 67 suites**, zero failures/errors/skips (52 seconds), +offline app `compileKotlin` passes (6 seconds), and six isolated Kotlin 2.2/JDK 21 mutants are caught. +Proof is in `.nodeterm/android-beta-build-9/`. No shared host contract changes. +**Physical empty-host variant passes (2026-10-04).** On installed beta 9, the otherwise-empty +private SSH fixture creates and opens a first Home shell. Native End removes its exact row and +Phone terminals group while SSH stays connected. New terminal then creates and opens a second +Home shell, then retained for the beta 10 update and subsequently ended in the A91/A94 flow above. This is not final fixture cleanup. +Proof: `.nodeterm/android-beta-build-9/checklist-20261004/a91-beta9-focused-results.json`. +A94's loading-label correction is delivered in beta 10 and the full installed empty-screen End/recreate/open/End cycle passes. +Item 32 stays Partial; item 1 separately brings the current tally to 10 Pass / 22 Partial / 32 Pending. + +**Historical beta-9 delivery:** `0.1.0-beta.9` / code `10`, clean source +`4d33a5b5366c99479b648086649205350c7752b1`, built in 43 seconds and updated the exact intended +Pixel with the retained signer in 6.46 seconds. The pulled installed APK matches SHA-256 +`719cfeea1dcf27900dd35692a59004ca07e8261b3f14bd43922f0706b6b4ab54`. +Three owned beta-8 shells survived the update and were rediscovered on beta 9, then all were +ended through verified exact-session cleanup. Historical source CI +[run `37144282865`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37144282865) failed the +device-checklist documentation mapping; debug/release APK and CodeQL jobs passed, private-beta +packaging was skipped. Do not treat it as green; the next push needs its own passing workflow. +This revision maps A91 into item 32 and isolates its Known gaps paragraph. The recorded local +688/67 source baseline predates the final documentation; fresh required gates follow the frozen +docs, and the final exact-head CI receipt is retained separately after pushing. +Independent SDK 36/37 signature/alignment, unchanged signed payloads, R8/service metadata and +source/hash provenance checks pass. The non-debuggable installation preserves install identity, +notification grant and app data; paired relay survival was unverified at that checkpoint and +is now proven by beta9→10 above. Historical receipts: +`.nodeterm/android-beta-build-9/artifact-review.json` and +`.nodeterm/android-beta-build-9/device-install-20261003/receipt.json`; private APK: +`.nodeterm/android-beta-9/nodeterm-android-0.1.0-beta.9.apk`. + +**Historical installed beta 8, now superseded by beta 9:** private `0.1.0-beta.8` / code `9`, from clean source +`b88d141528c1051964da07faf22cc7fa923c4846`, using the retained signer. Offline AGP built in +49 seconds; R8 keeps, signature, 16-KB alignment and source/hash provenance checks pass. +Independent SDK 36/37 signature/alignment, unsigned-payload preservation and R8 service-mapping +checks also pass. APK SHA-256: +`d373ad5c1790f714cb4464ad4a0a38c5ba9ab68e35103e54cf3aef5ce53081ce`. +Private APK: `.nodeterm/android-beta-8/nodeterm-android-0.1.0-beta.8.apk`; proof is in +`.nodeterm/android-beta-build-8/`. The exact Pixel 10 Pro / Android 17 received a 28.05-second +same-signer update without uninstalling. Pulled before/after APKs match beta 6 / beta 8, the public +signer matches, and code 9/name beta 8/non-debuggable metadata is confirmed. Install identity and +notification permission remain; the app starts and the existing manual-SSH host entry is present. +Opening that host reconnects over SSH and loads real driven projects. **New terminal** is visible +and enabled/clickable; no existing pane was touched and no terminal was created or ended. This +does not establish creation/persistence/End or paired relay-credential survival. Receipt: +`.nodeterm/android-beta-build-8/device-install-20261003/receipt.json`. +The later beta9→10 paired update above passes item 1 without uninstalling the working app. +Future private betas retain the same signer and increase the version code. Verify real SSH creation/cwd/input/history, disconnect/app-restart +rediscovery and exact End ([item 32](../docs/android.md#device-checklist)). Historical beta-6 proof +and its historical **7 Pass / 20 Partial / 37 Pending** checkpoint are preserved; focused A90 +proof changes item 32 to Partial. Item 1 and later item 36 pass, giving current **10 Pass / 22 Partial / 32 Pending**. Debug migration stays +conditional SKIP on the working Pixel. + +## What it does + +The APK is built by CI. The signed minified beta has real Pixel terminal/copy/keyboard and partial +SSH/link/lifecycle evidence; the feature rows below still require the full device pass. ✓ means the code is written for it and +tested where the layer allows, and the numbered +[device checklist](../docs/android.md#device-checklist) is what will check it. + +| | Android | Notes | +|---|---|---| +| Pair by QR (or pasted code, or a `nodeterm://pair` link) | ✓ | E2EE-sealed `/pair`, Ed25519 key made on the phone | +| Add a computer by its SSH address ("Add SSH server") | ✓ | For a computer with no pairing code: a headless Server Edition, or a macOS / Linux host you reach only over SSH. Shows the phone's key line (copy, share, or a one-line command) to add to `~/.ssh/authorized_keys`; Connect pins the SSH host key once the computer accepts that key, and shows the fingerprint to compare. **SSH only**: reachable by LAN/VPN, with no hosted push or relay fallback. Direct Git works on admitted local/driven folders independently of the actions service. A live selected-profile service supplies owned Board writes on Desktop/Server and delivery-only node nudges on Desktop. Current enabled local Linux/macOS tmux hosts also provide managed canvas New over SSH (A111); older hosts need the relay. Phone-owned plain SSH New is separate. Optional saved SSH profile folder supports custom Server data directories (A119). Optional one-time password setup confirms the SSH fingerprint before authentication, installs only the retained public key and verifies a fresh key-only login (A120). No keyboard-interactive/MFA or Windows | +| Direct connection on your network (SSH + tmux) | ✓ | Host key checked against the keys the computer names at pairing, then pinned (trust on first use with a desktop that names none); the tmux socket of the nodeterm on the computer, and the one a desktop that drives the computer over SSH uses (its sessions, project files and status slices are read there too). Finds a Server Edition's data dir | +| From anywhere (relay, E2EE, SAS approval) | ✓ | A current desktop approves the phone at the scan (its relay key rides the sealed `/pair` body), also when remote access is turned on only after pairing; an older one shows a code on the first relay connect | +| Late relay adoption (paired while remote access was off) | ✓ | Reads `~/.nodeterm/relay.json` over SSH, mints its own device token | +| Sessions, grouped like the desktop sidebar | ✓ | Needs you / Running / Sleeping, activity + context % | +| Terminal (co-attach to the live tmux session) | ✓ | xterm.js renderer, native input bar, special keys, swipe = tmux scroll. Tap a link (also one wrapped over several rows, or an OSC 8 link): the phone names its host and opens it only when you confirm, and only an http(s) one. The Copy chip opens a sheet of the screen's lines and links to select and copy or share. A copy the pane sends itself (OSC 52, e.g. vim's `"+y`) reaches the clipboard too, but tmux's copy-mode is out of easy reach on a touch screen | +| Retained terminal history search | both | Find searches all retained host output on the exact stream/pane, literal case-sensitive matches with line numbers and explicit caps; captured Copy search stays local | +| Dictation into the input bar | ✓ | A mic beside Send: Android's speech recognizer writes into the draft and never sends it. Settings → Dictation offers searchable languages/regions and System default; availability depends on the phone's speech service/models. The microphone permission is asked on the first tap; no mic shows on a phone without a recognizer. Not the on-device Whisper of iOS and the desktop (see [Known gaps](../docs/android.md#known-gaps)); the keyboard's own voice typing works too | +| Sleeping (Eco) session opened | ✓ | Relay wakes an eligible owned offscreen/closed-project node without switching tabs. Direct SSH offers an explicit wake tap. Both apply the agent's measured policy and preserve Pause/owner/process guards | +| Cold-start resume offer (the computer rebooted) | relay | Offers the agent's own `--resume `; never types it unasked. Over SSH a session that is not running is never created (it would lack its hook environment): the phone offers to open it through the relay when it is the computer's own; a session another computer's nodeterm runs there, or one no listing names, is not offered the relay | +| Terminal access to the computer's SSH projects | relay | They run on another host; the computer attaches them over its SSH connection. Direct terminal access through this computer needs relay; a current Desktop's SSH actions service can separately handle its owned SSH-project Board metadata and node nudges | +| New session (agent / shell) → registered on the canvas | SSH / relay | Current Linux/macOS tmux hosts create and register the session themselves over SSH (A111). Choose an open local folder project, agent or shell and an available account. Older hosts use the relay route. An uncertain creation is inspected, never sent again automatically; one Pixel 7a manual-SSH plain-shell creation/input/reopen/restart case passes at `40731381`; live-agent and other-route checks remain pending. | +| New plain terminal on an SSH host | ✓ (`A90`, installed beta 10/code 11) | Focused Pixel Home/project/custom cwd/input/history, restart/update/reconnect and exact End pass. Independent Phone terminals group; no desktop canvas/agent registration. Item 32 Partial: relay plain-shell creation/input/End pass, managed and cellular creation pending; A91 empty-host variant passes | +| Wake / refresh / rename / end session | relay and current Desktop SSH service (end: both) | Typed node verbs; SSH service acknowledges nudge delivery. Server has no renderer/node nudges; inspect actual wake/rename result | +| Kanban board, move cards, labels | both on current Desktop/Server | Typed selected-profile SSH service uses the actual WorkspaceStore save queue and mounted change broadcasts. Server serves local owned projects; Desktop can serve its own SSH-project metadata. Older hosts need an allowed relay | +| Source control: status, diffs, stage/unstage, commit, push/pull, recent commits | both | Eight typed verbs, admitted physical folder/repository-root jail, literal filenames and bounded results. Third-machine/no-folder projects refuse; uncertain writes never replay. Resolve merge conflicts on the computer or in a terminal | +| Inbox: approvals, questions, finished turns | ✓ | Request-owned held approvals; Always allow confirms a concrete original rule and scope. Full held Claude questions submit every single/multi selection through hook JSON. Legacy unheld multi-select opens the session; context % when known | +| Read-ack (reading a finished session clears it on the computer) | ✓ | `inbox.ack` over the relay, `~/.nodeterm/acks` over SSH | +| Usage (rate limits per account) | ✓ | From the agent-status mirror, with a pace line ("5h usage pace faster") when the reset time is known | +| All computers: every paired computer's Inbox and Usage on one screen | ✓ | With two or more computers paired: cards newest first across computers, each naming its computer and answered on it; one Usage section per computer that reports usage. Each computer's row shows how many of its approvals and questions are open, from saved snapshots in the host list; All computers watches and refreshes hosts while visible | +| Notifications | local | See "Notifications" below | + +## Before using it away from your computer + +**Native history scrolling is source-fixed and published at `1add0408` +([A129](../docs/android-audit-2026-09.md#a129), acceptance pending).** Host/Android +source adds negotiated `scrollV1` history pages. Native mouse-off gestures browse a bounded, +viewer-owned snapshot with zero foreground bytes; a valid view stays inert while the live app +changes mouse mode. Live, user input, resize or an explicit new intent closes it, and the next +new intent follows the application's actual requested mouse encoding. Copy and links use the +visible history rows while xterm continues parsing live output underneath. Unsupported/stale +hosts refuse, and uncertain input never replays. Existing known-tmux/direct-SSH wheel and +momentum checks keep their original scope. Leaf/backend recorder tests do not establish +physical Windows ConPTY or phone acceptance. Fresh 1012/105 protocol and offline app checks, +568/52 affected Vitest tests with four existing Windows process-tree skips, full TypeScript and +all-five/ten-step Android CI `37602046133` pass. At that earlier checkpoint, signed beta +18/code 19 was ready but not installed; beta 17 remained installed and the physical +checklist was unchanged. Actual app/WebView acceptance remains unverified. +See [the contract and evidence](../docs/android.md#native-history-scrolling). +iOS adoption of the additive host capability/pages is required for @eneskirca. + +**Held Live-button touch ([A130](../docs/android-audit-2026-09.md#a130), focused source fix).** +Touch-down now stops momentum and native pending movement, preserving history and its +display epoch until click. Three new JVM cases pass within 48/7 control/restored checks, and +three semantic mutants fail assertions. The earlier 512-ms/20-request/23-notch failure is +historical. Final gates/CI pass on published `5bda2c32`, and beta 19/code 20 is installed with +measured update preservation. Actual WebView/device behavior acceptance remains pending. + +The full device pass is still outstanding. The intended Pixel has the private beta and basic SSH +listing/input works. Its corrected code-3 update (`A85`) fills a 52×45 viewport and shows pre-attach +tmux history after swiping; font and keyboard changes resize the host correctly. The user's intended connection is to this Linux host over their +WireGuard VPN; the user confirms its terminal opens over mobile data with Wi-Fi off. +The user authorized local builds and pushing this branch. Each requested push requires green +Android workflow verification, with no PR requested and `A68` deferred. The beta-3 controlled +scroll checks did not satisfy the user; verify the corrected beta on the intended phone before +calling responsiveness resolved. Beta 5 implements momentum/native stopping but actual drag +still loses its touch target; beta 6 corrects that (`A89`). The SDK-37 +packaging follow-up `f5fd3821` passes 39 Python tests against each SDK 36/37 and ten new mutations; +CI4 still failed private packaging. [Run `37061593216`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37061593216) +at `c37798b6495b4b68df379d0ae80887c23104b66d` completed with all five jobs green, confirming +observed CI repair. Any later push still requires its own green workflow. + +1. Prepare and install the [private beta](#private-beta) below from a successful local build or CI + run of the current branch. The desktop must also include the host-side fixes you want to test. Future private beta + updates use the same private signer and a higher version code, preserving pairings. +2. For a VPN/SSH route, connect the VPN, use Add SSH server and compare the host-key fingerprint + with the computer. For the relay route, turn on remote access in Settings → Phone, pair, and open the computer in the + app. If either screen asks for a first relay approval, compare and approve its code there. +3. Turn the phone's Wi-Fi off. Open a terminal over mobile data, send a harmless command, answer a + question and a held approval, then reconnect after briefly enabling airplane mode. Confirm the + answers on the computer. For SSH, keep the VPN active and confirm the route stays SSH; for the + relay route this checks cellular connectivity and action/recovery behavior; the current live + join contract already passed the controlled relay-only browse check. +4. Keep the computer awake, nodeterm running, and the intended project/session mounted and awake. + For the relay route, keep remote access on. Current hosts support offscreen Sleeping wake + (A104); its remaining physical checks are pending. Background notifications use Android's periodic worker and may take longer than 15 minutes; there is no FCM. +5. Record these results, then finish the [64-item device checklist](../docs/android.md#device-checklist). + A successful build or cold start alone does not verify pairing, input or connectivity on the phone. + +## Build + +```bash +cd android +./gradlew :app:assembleDebug # → app/build/outputs/apk/debug/app-debug.apk +./gradlew -p protocol test # the wire layer; see below +``` + +Needs JDK 17–24 and the Android SDK (Android Studio's, or `ANDROID_HOME`). `minSdk` 26, `targetSdk` 35. +Android Studio's bundled JDK works, and CI uses 17. JDK 25 does not: the wrapper's Gradle 8.14.3 cannot +run on it (its embedded Kotlin compiler rejects the version while compiling the build scripts, so +`./gradlew` stops before configuring anything); that needs Gradle 9.1 or newer. Point `JAVA_HOME` at a +17–24 JDK if your default is 25. +CI builds the debug APK on every change under `android/` or to the desktop code the protocol tests +run (`.github/workflows/android.yml`; its path filter says which) and attaches it to the run. The +debug APK is not minified. Only the release build type runs R8 (`app/proguard-rules.pro`), and CI +builds it too, attaching unsigned build inputs +(`./gradlew :app:assembleRelease`, then `tools/check-r8-output.sh`). A missing `-dontwarn` therefore +fails CI rather than a first release (R8 reports the missing class), and so does one of the keeps the +script checks when it stops matching (the WebView bridge, the worker, BouncyCastle's provider tables, +one exception name). A keep that NEW reflection needs is not detected, because R8 renames or drops +such code without a word; add the keep and a line in `tools/check-r8-output.sh`. None of this proves +a minified APK fully works on the intended phone. The private beta has listed actual projects and +delivered basic terminal input over SSH; the corrected update exposes pre-attach tmux history. +The full device pass remains open. + +## Private beta + +This path prepares an APK for your own phone. The private signing key and signed APK stay on your +machine. The user authorized a local build for this first beta. The first unsigned release built +with AGP 8.9.1 emitted R8 Kotlin-metadata warnings (`A83`). The corrected AGP 8.10.1 release at +`fa71cb08072f399f24a81bfb361ea852a0275f3b` completed, including an offline rebuild and every R8 keep +check, with those warnings gone. The privately signed `0.1.0-beta.1` (code `2`) was installed on an +MI8 running Android 15 / API 35; after its first notification permission dialog, it showed the empty +Computers screen, Pair button and Settings with no crash markers. Installed metadata and refused +`run-as` confirm it is non-debuggable (minSdk 26, targetSdk 35). Its WebView is +`com.android.webview` `144.0.7559.76`. The user then identified this as the wrong phone. Only the +newly installed app and its test UI dump were removed; its newly authorized SSH key was removed +and the host's `authorized_keys` was restored byte-for-byte. It had no paired host, and no SSH +connection was attempted. At that stage the same code-2 beta was installed on the intended Pixel 10 Pro, +Android 17 / API 37, Vanadium WebView `154.0.8037.92.0`. The user granted notifications normally; +`run-as` is denied. Manual Add SSH server connects to this Linux host, with the authorized phone +key preserving existing authorized entries and the Ed25519 pin matching its public key. The app lists 17 +actual projects and a harmless terminal command entered on the phone executed in a controlled +temporary tmux window. The intended route is this host through the user's WireGuard VPN; the user +confirms its terminal opens over mobile data with Wi-Fi off. That code-2 terminal was one row (`A85`), and swiping did not expose old +history. The fix in `febe022a` supplies `MATCH_PARENT` WebView layout parameters; its Gradle +wiring guard and two mutations pass. Actual beta `0.1.0-beta.2` (code `3`) passed its offline AGP +release build in 45 seconds, retained-signer verification and in-place update. Host configuration, +SSH key/pin and notification grant stayed intact. The controlled terminal now fills 52×45, and a +downward swipe entered tmux copy mode and visibly showed rows printed before update/attach. +Inputs/logs are in `.nodeterm/android-beta-build-2/`; APK/checksum/metadata are in +`.nodeterm/android-beta-2/`. APK SHA-256: +`a022a399e23c81607a4a3664862b964ad781f0a589ab77dda902b4c2dc597eca`. +A− restores 56×48, the keyboard changes it to 56×25 and hiding the keyboard returns 56×48. +Esc leaves copy mode, and Send executes a second harmless draft command with its whole output line. +Only the owned temporary test window was removed; the original window/process and intended-phone +configuration remain. + +**User-reported mobile check:** with WireGuard enabled and Wi-Fi off, the user confirmed that the +intended Linux host's terminal opens over mobile data. This is separate from the ADB-assisted LAN +checks above. Mobile reconnect, approvals/questions, background behavior and the full 64-item +checklist remain open. + +History regressions in `d6619bf6` pass 47 focused real Gradle SSH/terminal/link tests with zero skips. Both JavaScript swipe-direction/disabled-scroll mutations, the real-SSH wheel-direction mutation and the two native layout-policy mutations were caught; production sources were restored. **Beta-2 verification:** all 609 protocol tests passed in 59 suites with zero failures, errors or skips (52 seconds); the offline app `compileKotlin` passed (7 seconds). + +**Historical A86 beta-3 update:** private `0.1.0-beta.3` / code `4` from +`40c4ee49592e2f92fc7e6e9b548ba88a33b1e2d3` built locally in 49 seconds, passed every R8 keep +and retained-signer packaging. Signature, alignment and source/hash provenance passed, with all +149 ZIP payloads unchanged by signing. APK SHA-256: +`dda44df7ebb541afccd18b428634d66246349ccb62fd202917b77a333fbadba3`. +The intended Pixel updated in place and preserved manual SSH registration/key/pin and notification +permission. It reopens the Linux host over SSH with a 56×48 test terminal. Five identical controlled +swipes finish at history position 25 versus beta 2's 85; reversal moves 25 to 20 and Esc leaves +copy mode. The phone returned to Sessions and refreshed; only the owned test session was removed, +with user panes untouched. Private proof/screenshot/package checks are in +`.nodeterm/android-beta-build-3/`, including `device-checks.json`. These verify drag gain/order/input +cancellation, not perceived smoothness or FPS. The user subsequently reports both lag and too +little movement; the current `A87` correction supersedes this gain policy. At that checkpoint, +restored-source checks passed all 638 protocol tests in 61 suites with zero failures, errors or skips (51 seconds), +and the final offline app `compileKotlin` (7 seconds). Thirty SSH/JavaScript/actor/wiring mutations were +caught. The later controlled phone trace records sparse updates and does not establish terminal FPS. +At that checkpoint, actual feel, custom wheel bindings and kinetic fling remained open under `A86`. +Push is authorized; each requested push requires green +Android workflow verification. +The matching phone trace records 42 bridge invocations versus 12 and 25 distinct presentations +versus 11 over twelve gestures; first invocations occur at 25–36 ms versus 84–90 ms. These show +more delivered updates, without establishing terminal FPS or end-to-end SSH latency. + +**Historical stable-touch beta (`A89`):** `0.1.0-beta.6` / code `7` uses source +`c4b1f6cf1009f293a658b6331d2ed1ab80aa36c6`. It targets the stable xterm screen behind changing +rows. Real-bundle redraw regression delivers 24 notches versus one with a detached touch target. +All 658 protocol tests in 63 suites and offline app `compileKotlin` pass; 35 JS, eleven native +and three CSS mutations were caught. Actual AGP release built in 43 seconds and passed every +R8 keep. Retained-signer packaging verifies non-debuggable metadata, signature, 16-KB alignment +and source/hash provenance; all 149 ZIP payloads remain unchanged by signing. APK SHA-256: +`4947133a6ccf9c2b1e775e76d7c24f564e087cf036e59eac4dca08162a076d3c`. +The intended Pixel received a same-signer update preserving app data; notification permission +is confirmed. SSH reopened with the retained key/pin at 56×48. A slow swipe produced 23 position +updates; a fast swipe kept moving for about 799 ms after its ADB command completed, and Esc +left copy mode for the following 1.4 seconds. A held touch stops coast at a stable 56×25 viewport, +with position unchanged through CANCEL after 1.2 seconds. Earlier keyboard-resizing touch checks +were inconclusive. The user confirms normal continuous dragging and coast both work now. +The final user check also confirms beta-6 manual-SSH connection and smooth scrolling on cellular WireGuard with +Wi-Fi off; cellular hosted relay remains untested. Reversal/lifecycle, FPS/custom-binding checks +and full device validation remain open +in the [handover](../docs/android-handover.md#progress-log). +Testing returned the app to Sessions, detached the owned client and removed only its exact owned +tmux session and phone UI XML; private `device-summary.json` records cleanup. + +**Historical kinetic beta (`A86`):** `0.1.0-beta.5` / code `6` uses source +`1ad2e94455a7adfb85d41212b12d36df39695324`. A fast release starts bounded decaying movement; +new touch/input/reset/font/lifecycle barriers discard unsent scrolling, preserving accepted +keys/replies and the in-flight operation. Bytes already handed to SSH's writer/network cannot +be recalled. All 658 protocol tests in 63 suites and offline app `compileKotlin` pass; 35 JS and +eleven new native mutations were caught. Its actual AGP release built in 47 seconds and passed +every R8 keep. Retained-signer packaging verifies non-debuggable metadata, signature, 16-KB +alignment and source/hash provenance. APK SHA-256: +`7cc68d384aeb21ab40800fa7c83f006dfbfefba16dd2e945967c8c5376f17655`. +The intended Pixel updated in place with notification permission intact, but its continuous +swipe still stops and shows no coast after the ADB swipe command completes. This actual failure led to `A89`; it is +not momentum-success proof. The newer beta-6 checks and user confirmation above establish +drag/coast success. + +**Packaging CI (`A88`, `f5fd3821`):** CI run `37058184031` passed its four main jobs but failed private +packaging. Real SDK 37 reproduces the new `V3.0 Signer:` format; the follow-up accepts exact +scheme labels while retaining one pinned certificate and verified v2. All 39 Python tests pass +against each SDK 36/37, and ten new parser/fixture-selection mutations are caught. The fixture +now uses CI's installed SDK 36/platform 35 explicitly. [Run `37061593216`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37061593216) +at `c37798b6495b4b68df379d0ae80887c23104b66d` completed with all five jobs green, confirming +observed packaging repair. + +**Historical beta-4 correction:** source `3cffb49d8cf64932260e914b42b3883331d0352d`, version +`0.1.0-beta.4` / code `5`, retains the same signer. All 646 protocol tests in 62 suites pass with +zero failures, errors or skips; offline app `compileKotlin` passes (8 seconds). Real xterm +focus/mouse/query reports preserve gestures, while actual keyboard/paste/IME input cancels them; +22 JavaScript and nine actor/native-wiring mutations were caught. `fed68fb3` verifies newer SDK +signer labels without relaxing the expected-certificate/v2 policy; 32 Python tests and ten +mutations pass locally. At that checkpoint, CI repair still required confirmation; the later +`f5fd3821` follow-up and green run above provide it. +The code-5 local AGP release built in 48 seconds and passed every R8 keep. Retained-signer +packaging verified non-debuggable APK metadata (minSdk 26, targetSdk 35), signature, alignment +and source/hash provenance. APK SHA-256: +`c64d6a8dea9621265f23a679104149e511ecd243fbdfa53ced401f4cb4f0f6b5`. +The intended Pixel updated in place, preserving manual SSH configuration/key/pin and granted +notifications; the installed app remains non-debuggable. Its owned 56×48 terminal now reaches +positions 25, 45, 70, 100, 130 on the same five 1000-native-pixel/350-ms swipes, versus beta 3's +5, 10, 15, 20, 25. Each gesture produces 4–6 observed host-position changes, reversal moves +130 to 110, and the actual Esc chip leaves copy mode. Private inputs/proof are in +`.nodeterm/android-beta-build-4/` and the APK/checksum/metadata in `.nodeterm/android-beta-4/`. +These verify delivered movement/order/cancellation, not terminal FPS or satisfactory user feel. +The user subsequently confirmed more movement but reported missing momentum; the active `A86` +kinetic-scroll work above addresses that remaining gap. + +The original workspace holds the input provenance in `.nodeterm/android-beta-build-1/` and the +first finished APK/checksum/metadata in `.nodeterm/android-beta-1/`. At that first-beta checkpoint, +QR/code pairing, relay and mobile reconnect/background/answer checks remained open. The initial full protocol run passed 603 of 605 tests; the two SSH +harness-isolation failures (`A84`) are fixed in tests only (`1d6b04cc`). The earlier A84 full rerun passed +all 606 tests with zero failures, errors or skips, and both harness mutations were caught. CI was waived for the first local build; +every newly requested push still requires green Android workflow verification. Actions +artifacts on a public repository are downloadable by other signed-in users, so they contain only +unsigned build inputs and checks. + +1. Obtain verified unsigned release inputs, using the authorized local build or CI. Each later + beta update needs a higher version code and the same private signer. + + **Local build:** use JDK 21, the Android SDK and a clean committed source revision. From + `android/`, select the beta versions, build with the pinned wrapper, and check the R8 keeps: + + ```sh + NODETERM_ANDROID_VERSION_CODE=13 \ + NODETERM_ANDROID_VERSION_NAME=0.1.0-beta.12 \ + ./gradlew :app:assembleRelease --stacktrace + sh tools/check-r8-output.sh app/build/outputs/mapping/release + ``` + + After both commands succeed, record the actual APK/R8 hashes in `beta-build-inputs.json`. The + packager requires these keys; `buildOrigin: "local"` distinguishes this from CI. From the same + `android/` directory, the following records version `13` / `0.1.0-beta.12`: + + ```sh + python3 - <<'PYTHON' + import hashlib, json, pathlib, subprocess + def sha(path): + with path.open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() + apk = pathlib.Path('app/build/outputs/apk/release/app-release-unsigned.apk') + r8 = pathlib.Path('app/build/outputs/mapping/release') + pathlib.Path('app/build/outputs/beta-build-inputs.json').write_text(json.dumps({ + 'schemaVersion': 1, + 'buildOrigin': 'local', + 'sourceRevision': subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip(), + 'versionCode': 13, + 'versionName': '0.1.0-beta.12', + 'unsignedApkSha256': sha(apk), + 'r8MappingSha256': sha(r8 / 'mapping.txt'), + 'r8SeedsSha256': sha(r8 / 'seeds.txt'), + 'signed': False, + }, indent=2) + '\n') + PYTHON + ``` + + Keep those files from that same successful build together. Require the full protocol tests, + app/desktop type-checks and relevant desktop tests; a completed AGP build alone is not proof of + their results. AGP 8.10.1 supports this build's Kotlin 2.2 metadata (`A83`); do not use the earlier + warning-producing APK as the final beta. + + **CI alternative:** dispatch registered workflow `366764274` through the GitHub API or CLI + on the intended branch ref with **prepare_beta=true** and explicit beta version code/name. + Verify the resulting run's exact `head_sha` matches the source commit. Registered workflows + can use API/CLI dispatch on other refs; the UI Run button has a separate default-branch rule + ([GitHub workflow_dispatch documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_dispatch)). + Feature-branch pushes do not prepare beta inputs. Wait for all five jobs: **Protocol**, **App**, + **App release (R8, unsigned)**, **Private beta checks** and **CodeQL (Kotlin)**. +2. For CI, download `nodeterm-android-release-unsigned`, `r8-release-outputs`, and + `nodeterm-android-beta-build-inputs` from that same run and extract them into separate directories. + For either path, use the exact `sourceRevision` and version fields in `beta-build-inputs.json`. + The unsigned APK cannot be installed. +3. Use a private Android signing keystore you keep outside version control, preferably outside the + checkout. This first-beta session has prepared one in the original workspace's already ignored + `.nodeterm/android-beta-signing/`: alias `nodeterm-beta`, certificate pin in + `signing-identity.json`, and separate password files. Keep that entire directory backed up + privately; it is not included in the source bundle or patch. If starting on another machine + without an existing private signer, create one with JDK `keytool -genkeypair` and retain it for + all later updates. + Record the certificate's SHA-256 fingerprint using `keytool -list -v`; this public fingerprint + is the expected signer pin. The committed `app/debug.keystore` is public and the packager rejects + it. Keep the keystore and passwords backed up privately; changing the signer prevents updates. +4. Put each password in a separate local file with permissions `0600`, containing one password line. + On Linux or macOS with Python 3.11+, a JDK, and Android build-tools 36.0.0, run the following from + the checked-out repository. Replace the local paths and fingerprint with yours; take the versions + and revision from the local-build or downloaded input metadata. + + ```sh + python3 android/tools/package-beta.py \ + --apk /path/to/unsigned/app-release-unsigned.apk \ + --r8-dir /path/to/r8-release-outputs \ + --build-inputs /path/to/beta-build-inputs.json \ + --keystore /private/path/android-beta.p12 --key-alias nodeterm-beta \ + --store-password-file /private/path/store-password \ + --key-password-file /private/path/key-password \ + --expected-signer-sha256 YOUR_CERTIFICATE_SHA256 \ + --version-code 13 --version-name 0.1.0-beta.12 \ + --source-revision FULL_COMMIT_SHA_FROM_BETA_BUILD_INPUTS \ + --build-tools-dir "$ANDROID_HOME/build-tools/36.0.0" \ + --output-dir /private/path/nodeterm-beta-1 + ``` + + The output directory must be new or empty. The tool matches the APK and R8 checksums to the input + metadata from that local build or CI run, and checks the package, versions, SDK levels, + `debuggable=false`, R8 runtime keeps, alignment and the APK signature. Only after those checks + pass does it produce the APK, its `.sha256`, and `beta-metadata.json` with the signer and source + revision. This verifies packaging; it does not prove the app works on a phone. +5. Verify the checksum in the output directory (`sha256sum -c *.sha256` on Linux, or + `shasum -a 256 -c *.sha256` on macOS). Sideload that APK onto your phone, then run the mobile-data + preflight above and the [device checklist](../docs/android.md#device-checklist), including item 5 + on this minified build. + +If the public debug build is already installed, Android will reject the private signer as an update +to it. Moving to the private beta requires a deliberate one-time uninstall through Android +settings; uninstalling deletes every pairing and the phone's identity. Revoke the stale +phone entries on the computers and pair again. The packager never uninstalls or installs anything. +Clearing storage alone does not change an installed APK's signing certificate; uninstall the debug +APK before installing the private one. + +Every CI job that runs `./gradlew` first checks `gradle/wrapper/gradle-wrapper.jar` against Gradle's +published checksums (`gradle/actions/setup-gradle`, which also caches `~/.gradle`). Dependabot opens +weekly Gradle update PRs for `android/`, the protocol build and the wrapper included (not for +`tools/typecheck`, whose pins follow the app's by hand). The protocol's network and crypto libraries +(okhttp, sshj, eddsa, BouncyCastle) come in a PR of their own, so an androidx bump the app cannot take +yet (one that demands a higher `compileSdk`, say) does not hold them back. CodeQL analyses the app's +and the protocol module's Kotlin (`.github/workflows/android.yml`, job `CodeQL (Kotlin)`) whenever +that workflow runs: on a `main` push or a pull request that changes an Android-relevant file, +and by hand (`workflow_dispatch`). There is no `merge_group` trigger. A pull request that changes none is +compared with `main`'s analysis. There is no weekly re-scan of unchanged Kotlin, and the job is not a +required check. `GradleCiCoverageTest` pins all of this. The wrapper properties carry no +`distributionSha256Sum` yet, so the Gradle distribution itself is not pinned. + +**Debug builds are signed with a public key that is committed on purpose** +(`app/debug.keystore`, password `android`). That is what lets you install a newer CI or local +debug APK over an older one without uninstalling — uninstalling wipes every pairing. The other side +of "public": anyone can sign an APK that installs as an update over a debug build and inherits its +data, including the keys your computers trust. **Install debug APKs only from this repository's CI +or your own build.** Private release builds use their own retained signer; this first-beta session +has prepared it in ignored local state and installed the signed beta; full phone validation is pending. Debug builds +are also debuggable, which hands the phone's pairing credentials to anyone with adb access to it +(see [Security](#security)). + +## Layout + +- **`protocol/`** — everything that goes on the wire, as a plain Kotlin/JVM library with no Android + dependency, so it is tested on a JVM: NaCl box (a TweetNaCl port), the relay client, the host + RPC vocabulary, pairing, the direct-SSH transport, and the parsers for what the computer serves. + Its tests run the **desktop's own code** on the other end of the wire — `connectHostSession` and + `createPairingService` through a local relay broker (`src/test/interop/host-fixture.ts`, bundled + with the repo's esbuild and type-checked by the repo's `npm run typecheck` against the desktop + interfaces it implements), and the SSH transport against a real SSH server and a sandboxed tmux. + They need node and the repo's `node_modules` (esbuild, ws, tweetnacl) and skip without them; a + desktop checkout's existing `npm install` is enough. `npm ci --ignore-scripts`, which CI runs, is only + for a machine without the native toolchain: `npm ci` deletes `node_modules` first and the flag skips + the node-pty patch and build, so over a working desktop checkout it leaves node-pty unpatched (on + Linux, where node-pty ships no prebuild, not built at all) and `src/main/node-pty-patch.test.ts` red + until you run `npm install` or `npm run rebuild` again (`bootstrap-windows.bat` on Windows). The + SSH tests need tmux and `script(1)` (util-linux on Linux, BSD on macOS) and skip without them. +- **`app/`** — the Compose UI on top: pairing, adding a computer by its SSH address, the computers + list (each with its needs-you count), + a computer's Sessions / Board / Inbox / Usage tabs, the All computers screen (every computer's + Inbox and Usage), the terminal screen (with dictation into its input bar), a project's source + control, settings, background notifications. + +## Notifications + +The background check never makes a first relay connection (that would put the approval dialog on +an unattended computer): it uses the relay only for a computer that has already approved this +phone. After Deny, or an approval nobody answered, the app stops re-dialing the relay until you tap +Try again or open that computer. + + +The iOS app is woken by APNs pushes the nodeterm backend sends. That backend has no Android (FCM) +leg, so this app polls instead: checked about every 15 minutes in the background (WorkManager's +floor), and live while the computer or All computers is on screen. An individual computer or +terminal refreshes its host every 8 seconds; All computers refreshes every paired host while visible. +Other paired computers are not polled while you look at one individual computer, so their notifications still come from +the background check only. That includes a computer you just left: the app may still hold its +connection for a while, but no longer re-lists it when it pushes a change. All computers stops its +polls when it leaves the screen and closes connections after their last active user finishes. +Real-time push on Android needs an FCM leg in the backend. A computer added by its SSH address is +polled the same way, over SSH: the push a Server Edition gives an iOS phone is APNs-only. + +The live check leaves out what you are looking at: nothing of a computer is announced while its +Inbox tab (or the All computers Inbox) is on screen, and nothing a session's terminal shows while it +is attached. Those events count as seen, so no later check announces them either. An approval the +computer is holding for an answer (a hook-reply approval) is still announced with its terminal open, +because Claude paints that prompt only once the hold ends. A terminal that shows an error, a relay +offer or an approval code instead of the session hides nothing, and one still connecting leaves the +session's events for the next check. + +Tapping a notification opens that session's terminal; Back from it lands on the computer's Inbox. An +approval's notification carries **Approve** and **Deny**, and a question's carries its options when it +has at most three ("Option 1", "Option 2", …, or their text with **Show details in notifications** +on), wherever the Inbox card could answer it in one tap: a held approval (any agent's) or a Claude +prompt, a single-select question. Anything else carries **Open**. An answer needs an unlocked phone +(Android 12 and later ask for the unlock; on older versions the notification says to unlock first). +It goes over your network or through a relay that has already approved this phone, never through a +first relay connection, for the same reason as the background check. The phone cannot tell in +advance whether it is on the computer's network, so a computer paired with an SSH key always gets +the answers: tapped away from that network, the answer goes through the relay if it has approved +this phone, and otherwise is not sent ("couldn't reach"). The notification then says how it went +("Approved.", "Already handled.", or why nothing was sent, with a tap that opens the session). An +answer is sent at most once: if Android interrupts it and runs it again, the second run sends nothing +and the notification says the answer could not be confirmed. A notification left in the shade after +its request was settled and dropped from the computer's list (after 6 hours, or 50 later events) +types nothing into whatever prompt the session shows by then: it says to answer in the session. + +Each Inbox event raises at most one notification: only events younger than 6 hours are announced, +and the phone remembers, for each computer, the ones it has announced, you have read or you had on +screen for a day after it last saw them (longer when the computer's clock runs ahead), so nothing +still eligible is forgotten. Forgetting a computer forgets that too; pairing it again keeps it. One +event can reach the phone through two computers: a paired desktop lists the sessions of its SSH +projects, and so does the SSH server they run on when you added that one too. It is still one event: +announcing, reading or looking at it under either computer counts for both. + +A notification names the session, the computer and the kind of event ("Needs you — build-bot", +"Needs approval"), but not the event's own text: the command, file or question, or the agent's last +message. Android shows a notification's full content on a secure lock screen unless you hide +sensitive content there. **Settings → Show details in notifications** (off by default) adds that +text. A lock screen set to hide sensitive content shows only the title ("Needs you — ", or +"Completed — " for a finished or interrupted turn) and the computer's name either way. The +iOS app does receive the detail, in the push the desktop sends. + +## Security + +- The phone's relay identity (a Curve25519 box key) and SSH identity (an Ed25519 seed) are + generated on the device and stored encrypted under an Android Keystore AES-GCM key. Only their + public halves are ever sent anywhere; the next point is how the private halves can still be taken. +- **The debug APK is debuggable.** AGP marks every debug build + `android:debuggable`, so anyone with adb access to your unlocked phone while USB debugging is on + (from a computer the phone has authorized, or by accepting the prompt on it) can read the app's + files with `adb shell run-as dev.nodeterm.android` and attach a debugger to the running app. The + Keystore never hands out the key those files are sealed under, but it lets any code running as the + app use it, so that is enough to pull the phone's pairing credentials: the SSH private key your computers + accept, the relay box secret and the relay device token. A signed, non-debuggable release build + has been built and privately signed through the [private-beta path](#private-beta). Its wrong-test-device + installation was removed; the intended Pixel now has the same private beta, with `run-as` denied. + Real Pixel paired update, SSH/input/font/history, clipboard and keyboard checks work; ten complete checklist + items pass, and encrypted paste pairing/live relay browse/input plus synthetic shipped-hook + Approve/Deny/expiry and mounted relay question/copy-mode answers also work. QR/cellular-relay/SAS/worker + and remaining device variants remain unverified. Keep USB and wireless debugging off when not using them. +- **If someone else may have had adb access, pairing again is not enough.** The phone keeps its SSH + key, its relay box key and its relay device id through a re-pair, so each computer would trust the + same keys again. Give the phone a new identity before it pairs: + 1. Revoke every entry for this phone on each computer (nodeterm → Settings → Phone → Revoke). + 2. Uninstall the app, or clear its storage (Android Settings → Apps → nodeterm → Storage → Clear + storage; the names vary by phone). Either one deletes the app's stored keys and every pairing, + and the app then makes a new SSH key, relay box key and relay device id. + 3. Pair each computer again. + + A computer without Pro cannot revoke the old relay device token at the relay (that request is + signed with the Pro entitlement), so whoever took it may still reach that computer through the + relay with the old box key. For a confirmed recorded last-key revoke, the computer unpins that key and refuses it until restart, then requires approval again. Missing key association/revoker is unconfirmed; another pairing authorizing the same key is retained (A113). Read the Settings outcome before assuming remote access ended. Approve only while your own phone shows the same code. +- Dictation (the terminal input bar's mic) goes through the phone's speech recognition service, + which on most phones is Google's and may send what you say to its servers; it is not the on-device + Whisper of the desktop and iOS. The microphone is used only after a tap on the mic, until the + sentence ends or the terminal screen leaves, and the app keeps no audio. Type anything you would + rather not send to that service. +- Nothing of the app's goes into a backup or a phone-to-phone transfer. `allowBackup="false"` stops + cloud backup, and the manifest's data extraction rules stop the Android 12+ device-to-device + transfer, which ignores `allowBackup`. A new phone starts unpaired; pair it again. +- The relay device id goes with the relay key: from this build on, if the key ever has to be + created again (it was lost), a new device id is minted with it, so the phone does not present an + old device id with a new key. A phone whose key an older build already replaced keeps its device + id until the app is reinstalled. +- Relay traffic is end-to-end encrypted (NaCl box under a per-session HKDF key) and checked exactly + as the desktop checks it: role byte (no reflections), strictly increasing sequence numbers (no + replays), no re-key once ready, and the host key pinned from pairing. +- The computer names its SSH host keys in the sealed pairing answer, and the phone's first connect + must present one of them; a server that does not is refused before the phone's key is offered. An + older desktop names none, and the first connect is then trust on first use. Either way the key is + pinned on the first connect that authenticates (a server that refuses the phone's key is never + pinned). A changed key is never used over SSH; in Auto the phone goes on to the relay, which + verifies the computer separately, and shows a warning. If the computer's SSH server uses a host key + nodeterm on the computer cannot read (one with no readable `.pub` beside it, or one named only in a + config file that only root can read), SSH to it stays refused even after pairing again, which re-reads the + same keys; the relay still reaches it (see Known gaps in `docs/android.md`). +- Over the relay (and only there) a current desktop also reports its LAN address and SSH host keys + as they are now, and the phone updates the computer from that: the address it dials on your + network, and the pin, but only for a key the phone was just refused on your network that the + computer confirms is one of its own (the next connect then has to present one of its keys). A pin + that still works is kept, even when the computer does not list it. So a moved address or a + reinstalled computer's new key needs no new pairing; the new key is accepted on the connect after + the one that met it. Nothing learned over SSH ever changes them. A host certificate is pinned as the + key it certifies, so a renewed certificate is not a changed key. +- A computer added by its SSH address has no pairing behind its first connect, so compare the + fingerprint the Add screen shows with the computer's own (the screen gives the `ssh-keygen` + command). A changed key stops it; forget it and add it again only if you know why it changed. To + revoke the phone there, remove the line ending in `nodeterm-android` from that user's + `~/.ssh/authorized_keys` (the same key every pairing installs, under another comment). The phone + can use a password once for explicit key setup, after you confirm the displayed host fingerprint + (A120). It never saves that password; ordinary connections use the retained phone key. +- No cleartext HTTP anywhere; the LAN `/pair` POST runs over a raw socket and is sealed to the host + key from the QR. Its answer is read as untrusted: at most 64 KiB, within 45 seconds. + +Design notes, the protocol mapping and known gaps: [`docs/android.md`](../docs/android.md). + +## A113 + +**A legacy pairing can report a successful revoke without proof that relay access was removed (2026-10-04).** + +Fixed in `349b791a`. Desktop revocation now reports `unconfirmed` for a saved pairing with no +recorded relay key or no available revoker, and `retained` when another saved pairing still +authorizes that exact key. Settings shows these outcomes separately from local SSH-key removal +and Pro expiry. An absent relay outcome from an older main process also remains unconfirmed; +the confirmation dialog no longer promises removal of every connection. Confirmed last-key +unpin/session closure remains required for the clean relay receipt. Seven affected main/renderer +suites pass 111 tests; eleven isolated assertion mutants are caught with passing control/restored +runs. The source-bound mutation fixture uses a temporary-home service boundary and mounted React. +These additive local Desktop IPC outcomes do not change the phone wire contract. Full legacy +identity association/migration and physical relay revocation remain open; no unrelated approved +key is guessed or removed. + +## A114 + +**Android dictation has no language choice (A59 follow-up, 2026-10-04).** + +Fixed in `6b989e59`. Settings offers a searchable language/region picker using human-readable +locale names and an explicit System default. The phone-wide preference is separate from host +identities and is frozen when a dictation starts. System default omits the native language extra; +a chosen language sends its canonical BCP47 tag. Partial/final results still fill only the draft, +and microphone, cancellation and lifecycle rules remain unchanged. The bounded catalogue lists +locales, not installed speech models: native unsupported/unavailable errors stay visible. +Three focused protocol classes pass 39 methods, offline app compilation passes, and eighteen +isolated compiled mutants fail assertions with passing control/restored runs. Native intent/UI +wiring is covered by source guards; the cached Kotlin/Compose compilation is separate evidence, +not a recognizer/device pass. Physical language availability, recognition and layout checks remain +pending. No host wire contract changes. + +## A115 + +**Re-pairing leaves local host preferences and lets retired connections publish late (2026-10-04).** + +Fixed in `0a71c028`. Replacing a paired record retires its route, relay approval, +managed-creation checkpoint and old token while preserving unrelated computers and same-ID preference +updates. Forget and re-pair retire the exact old connection objects; a shared session-admission +barrier covers local record/token publication, including incoming IDs with no previous key match. +No network await runs under that barrier. A failed local secret write retains the record, while its +old socket remains retired. This is a local monitor fence, not a crash-safe Keystore/preferences +transaction. + +Owner-bound lifetime leases cancel pending dials and prevent late pin, LAN, relay-token, approval, +notification or checkpoint publication into a successor, even when it reuses the ID and key. +Normal disconnect still permits reconnect and retains an uncertain creation receipt; permanent +retirement never reconnects. Both primary and side relay results are retained before cancellable +handoff and closed on cancellation. Quiet notification reach uses the exact session without a +reverse manager lookup. Closing local polling does not revoke an existing Desktop SAS consent. + +Private controls/restored runs execute 177 repository methods and 29 actual +production HostStore adapter cases against in-memory Android interfaces. 73 isolated variants +fail assertions. One additional redundant retired-guard experiment survives equivalently because +retirement also invalidates every admitted generation; it is recorded separately and is not counted +as caught. Native wiring remains source-guard evidence, with app compilation checked separately. +No phone, live transport, physical durability or full legacy Desktop identity migration pass is +claimed. No host wire contract changes. + +## A116 + +**Pairing can advertise a Docker/VPN address the phone cannot reach (A49/A74 follow-up, 2026-10-04).** + +Fixed in `a6b3e88b`. Desktop Settings → Phone lists current IPv4 adapters and an Automatic choice. +The saved selection is an adapter name, so each new QR and authenticated relay LAN report follows +its current address across DHCP. An unavailable explicit adapter prevents a new QR and omits the +report's dial address; it never silently substitutes another adapter. Automatic prefers a physical +adapter on POSIX, retains Windows' validated current route hint, and permits a virtual-only fallback. +The list describes current local addresses, not proof that the phone can reach them. Windows remains +relay-only; browser Server pairing/network controls deliberately remain unsupported. + +Both Settings and quick pairing acknowledge pending settings writes before starting. Changing the +choice hides/stops the old QR first, offers an explicit failed-save retry, and cannot restart after +its visible row is hidden. Real global-search rows work too; hiding that row retires only its own +listener, including while its parent hook remains mounted. Old replies/events cannot stop or update +a newer pairing view. The legacy exported picker helpers also use the shared policy. + +Isolated Desktop controls/restored runs pass 156 methods; one unrelated pre-existing worker-spawn +guard is explicitly filtered in that private sandbox and remains part of the full merged gate. +Root's actual local HTTP-listener controls/restored pass all six methods. Five producer→Android +methods call the real policy, QR builder and LAN reporter with a fake OS table/public-key directory, +then the existing Android parser and LAN-refresh policy. They prove paired-host identity/pin +preservation, not Keystore or approval publication. The three final proof groups catch 56 assertion +mutants (32 Desktop, six real HTTP, eighteen interop); preliminary receipts remain separate. +The existing wire shape is unchanged and the Android client needs no parser change. iOS @eneskirca +should adopt the existing authenticated `lan` field; it now reflects the saved Desktop adapter. +Physical QR/VPN/DHCP, search UI and multiadapter checks remain pending. No external message sent. + +## Direct SSH coattachment follow-up (A13, 2026-10-04) + +Fixed in `ed2965be` after the beta-14 APK checkpoint. A current Desktop app attaches beside +external SSH/relay clients, then replaces only a live app painter attested by an exact PID/birth +receipt in the same private userData profile and tmux socket/session. Both old and incoming client +identities are rechecked in the tmux command queue before an exact-client detach. A failed or +departed new attach cannot evict the old viewer. Unknown, legacy unmarked and other-profile clients +survive; missing process attestation preserves viewers. Existing per-manager joins and grid policy +remain. Exit/quit releases the exact receipt; only proven stale receipts are pruned within bounds. + +Private controls/restored pass 122 focused tests and full TypeScript checking. Twenty-four distinct +isolated mutations fail assertions; three critical variants are additionally caught against the +actual tmux server. Seven real private-socket tmux control-client methods pass without skips on +tmux 3.7c: both phone/app attachment orders, unequal native pane grids, remounts, foreign/legacy +clients and stale-client races. Control-client height formatting is empty on this host, so grid +evidence reads the native pane and checks phone-only restoration. + +An additional Linux smoke at source `32d412b6` passes eight checks using cached Electron 42.11.3, +Node 24.19.0, actual node-pty and tmux 3.7c. It exercises the production `PtyManager` and painter +tracker in a private home/tmp/run/PID/network namespace: external/app attachment in both orders, +same-manager joins, selective app remount takeover with exact shell output retained, and normal +quit receipt cleanup while the external client and shell survive. Observed native pane grids +return to 56×48 after the 100×40 app replacement leaves. The in-memory `CorePlatform` adapter is +an explicit IPC boundary; no actual renderer, SSH handshake, relay, Pixel, macOS or Windows runtime +pass is claimed. The cached native binding, Electron binary, all 96 bundled source inputs and +external node-pty wrappers are hash-bound and unchanged across the final run. + +The private painter receipts are internal app state, outside the phone's named workspace/status/ +project files; no host-service verb, payload, mirror or client wire shape changes. Android keeps +its exit-zero reattach fallback for older hosts. The same host policy preserves iOS SSH clients; +@eneskirca should verify physical coattachment. Prepared beta 14/code 15's Android client remains +unchanged and uninstalled; host-side verification uses the later source. The device ledger stays +10 Pass / 22 Partial / 32 Pending, and phone testing remains paused. + +## A117 + +**Relative private HostKey declarations can be opened by a configuration Include (2026-10-04).** + +A relative `HostKey` is still excluded from SSH anchors because discovery does not know the +daemon's working directory. Its normalized private basename now also excludes lexical and +canonical Include candidates before they are opened. Explicit public `.pub` declarations remain +readable, and relative declarations share the existing bounded declaration budget. This is a +conservative exclusion by name; it does not infer relative paths or discover unknown aliases. + +All 22 host-reader methods pass on this Linux host, including the existing real FIFO race. +Seven isolated reader mutations fail assertions with passing controls/restored runs. The real +pairing-service → Android regression seals only a disposable server's permitted key, preserves +the anchors in the phone record, accepts that real SSH server and refuses a second real server. +The original source and removal of the canonical exclusion both fail that regression; restored +source passes. Private-file inputs are synthetic config sentinels, never actual private keys. +Proof is retained in `.nodeterm/android-relative-hostkey-2026-10-04/`. + +The sealed field and Android production code are unchanged; the actual interop fixture is updated +with the producer. iOS @eneskirca should verify the existing anchor handling against the current +host. Prepared beta 14 remains valid client source and is not installed. Physical checks remain +pending; phone testing is paused and the ledger stays 10 Pass / 22 Partial / 32 Pending. + +## A118 + +**Eligible older pairings cannot associate their existing relay key with a saved device (2026-10-04).** + +The standing phone host now offers `pairing.relayKeyChallengeV1 {pairingId}` and +`pairing.relayKeyProofV1 {challengeId, signatureB64}` only after the normal relay approval gate. +A legacy pairing is eligible only when its exact host-minted UUID has one unambiguous, canonical +Ed25519 public key under the host's existing `nodeterm-ios-` authorized_keys attribution. +Missing, option-bearing, duplicate, malformed, replaced or unsafe files refuse proof; relative +identity guesses and device names are never used. + +Each connection holds at most one ephemeral challenge and one in-flight inspection. It binds +version, challenge UUID, pairing UUID, pinned host box key, authenticated peer box key, raw SSH +public key, random nonce and expiry. All fields use the fixed newline encoding in +`src/shared/relay-pairing-proof.ts` and Android `RelayPairingProof.Challenge.signingBytes`. +Both wall and monotonic host time enforce 60 seconds. Every proof attempt consumes the challenge +before awaiting publication, including malformed signatures; another session cannot use it. + +Android attempts the optional proof only after an approved first listing, using its existing +encrypted SSH seed. It validates the captured host, peer and SSH keys before signing, checks the +current record/lifetime around awaits, and never creates an identity, changes approval or tokens, +or retries an uncertain proof. Missing identity, unsupported older hosts and ordinary optional +errors preserve browsing; cancellation propagates. A socket closed during proof cannot be handed +off as connected, and a later network close after proved approval is not reported as Deny. + +The actual host pairing queue stages a private association, then rechecks the bounded exact +registry snapshot, attributed public key, approval, connection lifetime and expiry immediately +before synchronous atomic publication. Existing same-key association is idempotent; conflicts and +deleted/replaced entries refuse. This is a same-service queue, not a cross-process transaction. +Normal revocation can then unpin/cut the exact proven last key; a sibling pairing still authorizing +that key keeps the explicit retained outcome. + +Focused controls pass 87 host and 15 Android helper methods. Forty compiled host and 25 compiled +helper mutation variants fail assertions. Seven real producer→Kotlin encrypted interop methods +cover sealed legacy pairing, actual signature verification, exact publication/revoke, distinct and +shared-key siblings, wrong SSH identity, cross-session/consumed challenges, old-host fallback, +unapproved access and the close/callback ordering. Six additional interop variants fail assertions +with passing controls/restored. The public-pin adapter, human decision, OS address and unused PTY +are explicit fixture boundaries. Private evidence: `.nodeterm/android-legacy-relay-proof-2026-10-04/`. + +This is an additive host/Android contract. iOS @eneskirca needs the same retained-key signer and +exact encoding to repair eligible legacy records; old iOS remains usable. A phone without its +retained SSH seed, its matching attributed SSH public key in the host's authorized_keys or SSH support still needs re-pairing. This proof never +bypasses first SAS approval and does not solve hosted FCM or A93. New Android source needs a later +APK than beta 14; no new installation or physical/live transport pass is claimed. Phone testing +remains paused, beta 10/code 11 is last installed, and the ledger stays 10 Pass / 22 Partial / 32 Pending. + +## Prepared private beta 15 and verification checkpoint (2026-10-04) + +`0.1.0-beta.15` / code `16` is built from clean signed source `aa902d0a52ba8ccfa0be57e073c350e90cfad5d3` and includes +A118's optional retained-SSH legacy relay identity proof. The host must also run this source for +migration; older or unprovable hosts remain browsable. A117's relative private-key Include fix and +A13's coattachment fix are included in the host source. + +Required source checks pass: 912 protocol methods / 94 suites, zero failures/errors/skips; +offline app compile; 1,919 affected desktop methods with exactly ten known macOS/Windows skips; +full TypeScript checking. All five exact-source Android jobs pass: [source CI](https://github.com/CPlusPlus17/nodeterm/actions/runs/37228985208). +A118's focused proof remains 87 host and 15 helper methods, 40 + 25 compiled assertion-caught +mutants and seven actual producer→Kotlin methods with six additional assertion-caught variants. + +The local release build uses a fresh tracked Android-only snapshot, cached AGP 8.10.1/Kotlin 2.2.0/ +Gradle 8.14.3/JDK 21 and `--offline`. R8 runtime keeps and 56 independent artifact checks pass: +SDK 36/37 signatures and 16 KB alignment, retained signer, actual version/manifest/provenance, +service-loader mappings and unchanged terminal/native assets versus beta 10. +Private APK: `.nodeterm/android-beta-15/nodeterm-android-0.1.0-beta.15.apk`. +SHA-256: `c1e707c8a18555db7e01129b709d39db342128f3c54d8103d548cc177bc9abaf`. +Signer SHA-256: `c610c3a0b637a8005b6fd858587e8cbf5260622648bc1cd97c0de0ab5f146dbd`. Private build/review evidence: +`.nodeterm/android-beta-build-15/`. The APK, signer and proof remain outside source exports. + +Live A105/A106 application is still unverified. An isolated installed Claude 2.1.289 fixture uses +the actual managed hook server/script/writer with an in-memory CorePlatform. Its fresh-context, +read-only credential/config and fixed-tool guards pass 34 static checks and the exact offline +mount/auth gate. The first attempt hid the system DNS target and timed out before tools; a +separate corrected fixture exposes only that regular resolver file read-only. It reaches the +provider, but receives authentication HTTP 401 before any tool request. No application pass is +claimed. Original profile/source/binary/resolver metadata remains unchanged; raw diagnostics stay +private and no credential refresh/reset is attempted. After the existing CLI account is usable +for inference, rerun the isolated remembered-rule/full-question checks. This gives no physical, +renderer, SSH or hosted-relay proof. + +Phone testing remains paused. Beta 10/code 11 is last confirmed installed; beta 15 is prepared +and not installed. The ledger stays **10 Pass / 22 Partial / 32 Pending**. Next device work uses +this APK and current host source, checks saved key/pin survival and the expanded checklist, +then restores rotation and removes only owned QA host state. A25/A93 still require hosted-backend +maintainers; iOS @eneskirca owes the new proof contract and earlier mirror/verb/v2-answer adoption. +No PR is opened; A68 stays deferred until requested PR preparation. + +## A118 JUnit registration follow-up (2026-10-04) + +The focused Kotlin runner exercised all 15 helper methods, but the initial Gradle/JUnit run +registered only 14: the outer-timeout coroutine test inferred an exception-valued return. +Its explicit `runBlocking` signature now lets JUnit discover it. Actual offline Gradle +controls register all 15 with no failures/errors/skips. Removing the signature compiles and +passes 14 methods, then fails the exact method-registration assertion; restoration passes 15. +The seven actual host/Android interop methods were already registered. Private evidence: +`.nodeterm/android-legacy-relay-proof-2026-10-04/junit-registration/`. + +The required local gate now checks the actual JUnit helper inventory and the outer-timeout +method explicitly. This correction changes only a protocol test and documentation after +beta 15's `aa902d0a` source; its shipped Android runtime inputs and private APK are unchanged. +The APK remains prepared and uninstalled, live Claude application remains unverified after +provider HTTP 401, and the physical ledger stays 10 Pass / 22 Partial / 32 Pending. + +## A119: explicit SSH profile folder (2026-10-04) + +Add SSH server now accepts an optional absolute **Profile folder**. Existing computers can change +**SSH profile folder** in Settings → How to reach each computer. The saved phone-local choice +selects a custom Desktop/Server data directory ahead of all automatic discovery. Blank restores +discovery. A missing or invalid explicit choice reports an error and never selects another profile. +Board, project reads and all three managed-view stages use the same captured profile. Saving a +change invalidates pending connection work and clears old displayed rows while retaining uncertain +creation receipts; it never recreates a session. SSH host pins, pairing and relay credentials remain +unchanged. Paths are quoted literally and reject relative/dot components, controls and oversized input. + +Regression coverage uses actual Server config/platform/WorkspaceStore/mirror/actions producers, +a real private MINA SSH server and isolated tmux. The fixture supplies a plain-shell planner/native +create boundary; it does not run full Server boot, installed hooks or account probes. Native storage +coverage uses actual app classes with in-memory Android adapters. Ten protocol model methods, +three actual Server/SSH methods and the complete 62-method SSH/10-method workflow suites pass. +Four compiled SSH profile mutants fail their intended assertions. Final native control/restored +runs pass 19 cases and catch the retired-session mutation; seven earlier native variants have +separate historical source bindings. Private receipts: `.nodeterm/android-ssh-setup-2026-10-04/`. Physical setup/profile switching remains unverified; +phone testing is paused and the ledger stays **10 Pass / 22 Partial / 32 Pending**. At this source checkpoint, beta 15 was +the latest prepared APK and excluded A119. The beta-16 receipt below records its later packaging. +iOS implication for @eneskirca: offer an explicit saved SSH profile choice consistently for discovery +and managed attachment. No host verb or pairing payload changed. A25/A93 and A68 remain open; +no PR is opened. + +## A120: one-time password SSH setup (2026-10-04) + +Add SSH server keeps its public-key Copy/Share/manual command and adds **Set up with a password**. +The app inspects the SSH host key without authentication. Compare its fingerprint on the computer, +then explicitly confirm **I compared it — it matches** before entering the password. Password login +rechecks that exact key, installs only the retained phone public key, closes the setup connection, +and proves a separate pinned key-only login before saving the host. A changed key, refused password, +unsafe key-file target, uncertain command or failed key verification never saves a computer. +No setup write is automatically replayed. A lost acknowledgement may leave the public key installed; +use the normal key connection or check authorized_keys on the computer before starting a new attempt. + +The password is memory-only, absent from saved state/preferences, shell text and diagnostics. Leaving +or backgrounding setup cancels owned sockets and clears its password/fingerprint approval. JVM/library +transient copies cannot all be erased. Existing keys and restrictions are preserved; commented or +trailing key-like text is not an installed declaration. Symlink, nonregular, foreign-owned and hardlinked +targets refuse. These are bounded pathname preflight checks, not descriptor-atomic protection against +concurrent same-user replacement. Keyboard-interactive/MFA, password changes and Server installation +remain unsupported. Host pins, pairing/relay credentials and ordinary key-based connections retain +existing behavior. iOS implication for @eneskirca: confirm fingerprints before password auth and verify +fresh retained-key auth before saving; no host-service verb or pairing payload changed. + +Seventeen actual protocol methods pass against a private MINA SSH server and private POSIX HOME. +They cover fingerprint/auth order, other/restricted keys, uncertainty/no replay, both-stream output +limits, own timeout versus external cancellation and socket closure. Offline app compilation passes. +Seven isolated compiled semantic variants fail assertions, with 17 passing control/restored +methods and exact integrated source hashes. Private proof: `.nodeterm/android-ssh-setup-2026-10-04/password-final/`. +The full required gate verifies actual Gradle/JUnit method registration too. This is source/fixture proof. +Actual Android setup UX, ordinary PAM/account behavior and macOS runtime checks remain pending; +the controlled native OpenSSH receipt below adds wire/exec evidence. Phone testing stays paused, +with beta 10/code 11 last installed and **10 Pass / 22 Partial / 32 Pending**. Beta 15 excluded A119/A120; +the subsequent beta-16 receipt below includes both. A25/A93 require the hosted-backend maintainers; A68 stays deferred and no PR opens. + +## Prepared private beta 16 and verification checkpoint (2026-10-04) + +`0.1.0-beta.16` / code `17` is built from clean signed source +`2723845efac75cfcf2cd0a9fdfa11d9496699641`. It includes A119's explicit saved SSH profile folder and +A120's opt-in password enrollment, along with the earlier beta-15 changes. Missing explicit profiles +never fall back to another profile; enrollment saves a host only after a separate pinned key login. +Full offline protocol checks pass **944 tests / 98 suites**, with zero failures, errors or skips. +Offline app compilation, **151 affected desktop Vitest tests / eight files** with zero skips, and +full TypeScript checking pass. Actual JUnit inventories include the new setup tests. An obsolete +inline UI behavior source pin was removed; actual SSH authentication regressions remain. +All five Android jobs pass for the APK source: +[workflow 37233114167](https://github.com/CPlusPlus17/nodeterm/actions/runs/37233114167). + +The minified release build passes offline in **54.87 seconds**. Packaging uses the retained private +signer; all **56 independent artifact checks** pass, including official SDK 36/37 signatures, +nondebuggable version/manifest, R8/runtime keeps, payload/provenance and 16-KB ZIP/native alignment. +APK SHA-256: `9c5309c8cfb225daf59f7fac70be1760fb0abdaac36cb55005b958f2186c8ce3`. +Private APK: `.nodeterm/android-beta-16/nodeterm-android-0.1.0-beta.16.apk`. +Build proof: `.nodeterm/android-beta-build-16/`; behavioral/mutation proof: +`.nodeterm/android-ssh-setup-2026-10-04/`. Earlier failed or historical runs retain their separate +receipts; they are not counted as successful merged gates. The release receipt changes only docs. + +**Prepared, not installed.** Phone testing remains explicitly paused, beta 10/code 11 is last +confirmed installed, and the device ledger stays **10 Pass / 22 Partial / 32 Pending**. +When authorized, install beta 16 as a same-signer update and verify saved identities, profile +selection/switching/no fallback, password/fingerprint/cancellation UX on actual OpenSSH, and the +remaining managed-session, relay/cellular, notification and lifecycle matrix. MINA/POSIX fixtures +and in-memory app adapters do not verify physical Android or macOS behavior. Live Claude rule and +question application remains unverified after provider HTTP 401. A25 FCM and A93 fresh-desktop +relay recovery require the hosted-backend maintainers. For iOS, @eneskirca should adopt the saved +profile/enrollment UX and review the earlier host protocol changes. No PR opened; A68 remains +deferred until a PR is requested. Every push requires its own exact-head green Android workflow. + +## Native OpenSSH enrollment verification (2026-10-04) + +A120 now has an actual **OpenSSH 10.2p1** control run in addition to its MINA regressions. +The actual protocol sources at `bac1aa7fe90416b07dd0de47ac95049e817ce8b5` were compiled privately; +**28 distinct assertions / 40 passing assertion events** verify inspection against an independently +measured public fingerprint, password login followed by fresh pinned key login and exec, +retained unrelated keys, newline/mode handling, idempotence, consumed confirmation, rejected password +and host pin, commented key text, restricted keys, symlink/nonregular targets, and closed client sockets. +These are native fixture assertions, not additional Gradle/JUnit methods or physical checklist passes. + +The daemon ran as UID 1000 in a filesystem/user/PID sandbox exposing only read-only `/usr` and private +account files/home. Its password and SSH identities were newly generated for this test. It listened +only on loopback, with PAM, keyboard-interactive, forwarding, PTY and source penalties disabled. +This Fedora build warns that `UsePAM no` is unsupported; the asserted crypt/password and public-key +wire/exec flow nevertheless passes in that exact controlled configuration. This does not verify a +normal PAM/account backend, MFA, macOS, Android setup UX/Keystore, native cancellation/timeouts, +or profile/managed-session behavior. The earlier MINA tests retain their separate coverage. +Both disposable daemon runs are closed; their generated passwords/private keys/account hashes were +removed after the run. No host account, credential, SSH configuration, retained phone identity or +phone state was changed. Private proof: `.nodeterm/android-beta-build-16/openssh-native/`. + +The stale SSH class comment now describes A111's host-owned managed New support; missing existing +canvas sessions remain attach-only. It changes only KDoc, with the same source line count. +The prepared beta-16 APK remains source `2723845e`, code 17 and the same retained signature/checksum; +no new APK is built or installed for this verification/comment checkpoint. Fresh compilation in the +same AGP graph confirms every protocol class byte matches the immutable beta-16 build. This byte +comparison is a required gate before publishing the receipt. Required local gates and exact-head Android CI also remain mandatory. +Phone checks stay paused at **10 Pass / 22 Partial / 32 Pending**, with beta 10/code 11 last installed. +Actual Pixel setup/lifecycle, ordinary PAM accounts, macOS and live Claude checks remain open; +A25/A93 still need hosted-backend maintainers, iOS follow-up stays with @eneskirca, and A68 remains +deferred until a requested PR. No PR opened. + +### Relay-advertisement producer/SSH coverage (`A64`, 2026-10-05) + +Two actual private-SSH regression methods now check every public field from the desktop's +account-level `relay.json` writer, replacement, removal and profile isolation. A third method +checks the actual writer/remover and `fs-atomic` bundle inputs against Android CI path coverage. +All 75 affected protocol methods pass with zero failures/errors/skips. Four isolated behavior +mutants fail assertions, and control/restored runs pass. Private proof: `.nodeterm/android-beta-build-16/desktop-links-and-advertisement-receipt/a64/`. +The producer uses a temporary OS-home adapter during module initialization, restored before its +operations; no standing relay host, token mint, adoption, SAS, revoke or phone result is claimed. +No production contract or APK changes. Beta 16/code 17 remains prepared, beta 10/code 11 remains +installed, and phone checks stay paused at **10 Pass / 22 Partial / 32 Pending**. + +## Canvas terminal link leave follow-up (A121, 2026-10-05) + +The full Linux Desktop native hover check for A92 exposed a separate Canvas leave defect: +synchronous blur redraws xterm's DOM rows before its screen receives mouseleave, leaving the +link pointer active after a native non-drag leave in the SGR mouse case. A121 now defers only +owned blur to the next timer task and cancels it on re-entry, focus and cleanup before park. +Nine behavioral methods, all 63 affected terminal tests, full TypeScript and five assertion +mutants pass. Fresh full Linux Desktop control and restored builds each pass all eight native +UI cases at `01b4a2f5`; the old-bound A92 and synchronous-blur A121 mutants fail on their named +hover/leave assertions after trusted mouse delivery. Separate Linux Server checks pass at +`dcdf664a`. The later GPU-enabled eight-case matrix passes (Canvas4 hardware / Modal4 DOM); GPU +context recovery and native macOS remain unverified. No Android runtime, APK, external contract +or iOS change is involved. Phone testing remains paused: beta 10/code 11 is last installed, +beta 16/code 17 is prepared, and the physical ledger stays **10 Pass / 22 Partial / 32 Pending**. + +## Native Desktop wrapped-link and leave verification (A92/A121, 2026-10-05) + +Signed source `01b4a2f5` now passes the full actual Linux Electron Desktop UI check. Four fresh +full builds consume all **2,462 exact raw Git blobs**, with only the one approved source literal +changed in each mutant. Control and restored runs each pass all eight combinations of Canvas +and maximized Board modal, hard/soft wraps, and normal/SGR mouse reporting. A real plain native +NodePTY paints the long run; native trusted XTest tail hover shows the entire URL underline and +pointer, plain click opens nothing, Ctrl-click opens exactly the complete URL once, and a native +non-drag leave clears the pointer and underline. No direct provider or activation call is used. + +The old upward cap fails with `A92_TAIL_HOVER_MISSING` after calibrated native tail hover. +Restoring synchronous Canvas blur fails with `A92_HOVER_CLEAR_FAILED` after trusted hover, full +Ctrl-click activation and native leave, retaining pointer/link state. The final blur mutant fails +in Canvas soft/SGR; an earlier run also reproduced it in hard/normal mode. Nine controller tests +and five assertion mutants cover cancellation, replacement, same-object park/adopt and cleanup +reuse; all 63 affected terminal Vitest methods and full TypeScript pass. + +Private durable proof: `.nodeterm/android-beta-build-16/desktop-links-and-advertisement-receipt/` +contains `a92/results.json` with 72 source/build/runtime evidence files, `a121-unit/results.json` +and `a121-affected/result.json`. Network/PID/home isolation and unchanged source/tools/held output +checks pass. Earlier archive-line-ending refusal, phase-specific classification rejection and +quit timeout are preliminary recipe results; they were not counted as product failures or final +controls. The final disposable fixture uses explicit exit after recording the checks; ordinary +app quit is outside this proof. Synthetic SGR reporting does not verify a real TUI/tmux/SSH path, +Server Edition, GPU, macOS or Android. No APK, host/client contract, iOS adoption or physical +checklist promotion follows. Phone testing remains paused at **10 Pass / 22 Partial / 32 Pending**. + +## Native Server wrapped-link and leave verification (A92/A121, 2026-10-05) + +Signed source `dcdf664a4ffa0cf752350c29fc21843f74c93ae1` passes separate genuine Linux Server +control and restored runs, each with all eight Canvas/Board-modal, hard/soft-wrap and normal/SGR +cases. Four fresh full builds use 2,462 raw Git blobs; each separate `server:build` adds only +`server/main.cjs` to the unchanged 136 artifacts. Actual Node 26 runs the shipped Server entry. +Chromium loads its real login form and HTTP renderer with no Desktop preload, Node integration +or injected API. Passive observation confirms authenticated `/ws` handshakes, actual PTY create +requests/replies and binary output. Native trusted hover, full underline, plain/Ctrl click and +non-drag leave assertions pass. Both the old-cap and synchronous-blur mutants fail their named +assertions after calibrated native input; restored source passes again. + +Private proof: `.nodeterm/android-beta-build-16/server-links-receipt/a92/results.json` and its +105-file manifest bind source, builds, recipes and runtime. Three earlier browser-bootstrap +failures remain separate infrastructure evidence, with no terminal assertions or product finding. +Loading the actual login document before enabling debugger network observation resolves the +runner stall. Final owned Node children close on SIGTERM; disposable Chromium exits explicitly +after the report. This verifies Linux Server DOM rendering with a synthetic reporting-mode TUI; +real tmux/SSH, GPU, macOS, ordinary quit and Android UI remain outside its scope. + +No runtime or external contract changes follow from this verification. Beta 16/code 17 remains +prepared from `2723845e`, uninstalled; beta 10/code 11 is last installed. Phone testing stays +paused at **10 Pass / 22 Partial / 32 Pending**. Next: resume the Pixel update/checklist when +authorized; live A105/A106 needs a usable provider account after HTTP 401; A25/A93 need hosted +backend maintainers. The Android contribution is in this repository. No PR is opened; A68 waits +for requested PR preparation. + +## Native setup timing, installed PAM and remaining desktop checks (2026-10-05) + +A120 now passes four native OpenSSH cancellation/deadline cases at clean source +`7cf2bb339d73c98e7876a7ce1edec5f6f3585141`, with **76 case-scoped assertions**. +All 76 protocol source files were compiled privately with Kotlin 2.2/JDK 21 and SSHJ 0.39. +The pre-install gate admits real password authentication and the exact production exec request, +then holds it without running the installer; cancellation/deadline leaves authorized_keys unchanged. +The post-install gate retains the actual installed key, accepts a third connection and reads a +real upstream SSH banner before withholding it. That case does not complete a third key exchange +or key authentication. External cancellation remains CancellationException; the operation's own +12-second post-dispatch deadline reports HostUnansweredException. Neither returns a PairedHost. +Owned client sockets, gates and daemon descendants close; caller password buffers clear, consumed +confirmations cannot replay, unrelated key bytes/modes survive, and generated secrets are removed. +This is protocol/native proof, not physical HostStore, Keystore or Android background/setup UX proof. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/openssh-timing/`. +The earlier 28 distinct native assertions / 40 events and 17 JVM methods / seven product mutants +retain their separate inventories; these new fixture checks are not new JUnit methods or mutants. + +A separate **17-check native installed-PAM control** passes against OpenSSH 10.2p1 and this host's +unchanged Fedora PAM stack. It uses a namespace-only synthetic NSS/shadow account and a UID/GID +1000 client with every process capability cleared. Actual logs record one accepted password, +two accepted key authentications, three successful PAM account checks, and three matched session +opens/closes, with no PAM session/credential errors. Public fingerprint confirmation, independent +retained-key exec, wrong-password/pin refusals, key-file preservation, no replay and socket cleanup +pass. All 31 public installed-stack bindings remain unchanged, SELinux remains enforcing, and +generated credentials plus owned daemon descendants are removed. This reuses 513 class files from +the earlier timing compilation after verifying all 76 protocol source bytes; only the 11 standalone +driver class files are freshly compiled. It does not verify real-user policy, MFA, ordinary systemd +user-session registration, macOS, or Android UI/storage. Earlier setpriv and private-log traversal +failures are retained as failed fixture attempts, without product fixes or mutation credit. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/runtime-checkpoint-supplement/`. + +The earlier RTX 4090 WebGL2 probe verified the environment only; the successful PAM namespace +admission performed no authentication. Their historical scope is unchanged. Later product GPU +and installed-PAM results supply the separate evidence recorded here. + +Earlier focused-terminal Ctrl+Q attempts remain nonpositive. V5's trusted renderer capture and +main before-input observations do not establish `defaultPrevented` after xterm's target handler. +Minimal Electron Quit controls verified the environment, not product Quit; failed fixture and +classifier attempts remain separate from the successful runs below. + +**Historical Linux Desktop runtime checkpoint:** current source `972f7994` differs from built +Desktop `01b4a2f5` only in six documents. Native File-menu Quit/Cancel and a separate outside-terminal +Ctrl+Q/Cancel both pass, followed by native File-menu confirmation and natural zero exit. Cancel +preserves the exact own PTY/workspace/window; Quit retires the own PTY/hook and saves state. +The GPU-enabled eight-case link matrix passes with eight complete URL opens: four Canvas cases +use the actual NVIDIA RTX 4090 xterm WebGL2 context, real glyph pixels and production 2D link +underline before/hover/leave; four Modal cases intentionally use DOM. The single GPU pilot stays +separate. A real shipped-tmux Canvas soft-wrap/inner-no-reporting pilot also passes one native +hover/plain-click/Ctrl-click/leave case while preserving the exact pane/client and shipped mouse-on +options. GPU and tmux fixtures use explicit `app.exit(0)`, supplying no ordinary-Quit proof. +These do not verify GPU context recovery/compositor pixels or full tmux/SSH matrices. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-checkpoint-20261005/`. + +**A122 is fixed in `9613cec519c1469aff10371bf0acf97adb90ed1a`.** Desktop Canvas/Modal now keeps the +exact native Quit chord ahead of xterm on keydown/keyup under either shortcut policy, without +preventing the native menu. Server retains Ctrl+Q/XON. 133 focused policy tests, 218 affected tests +in three files, full TypeScript and six assertion-caught isolated mutations pass. The fresh full +Desktop build is bound to that exact source (snapshot `57e09375369a1a68f0c28b7975616561aad6715a4dc05a6fc5eeb00d0dc0706c`; +build receipt `86eb82912ab7aa8a853a7236bacebc40ffbd9eff6845f3b79caaac83a029f5ff`). +All four native Linux cases pass: Canvas/Modal × app-first/terminal-first. At dispatch the actual +owned xterm textarea and main focus IPC are true; Ctrl+Q opens the real parented Quit dialog. +Native Escape Cancel retains the exact PTY and saved state; native File → Quit / Tab / Enter then +exits naturally with code zero, retires every admitted PTY birth and removes the own hook endpoint. +No signal, `app.exit`, renderer-dispatched key or API Quit supplies a pass. These are four separate private +home/network/PID/Xvfb cases, with zero link cases/URL opens; native macOS/Windows remains unverified. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-native-quit-20261005/`. + +The A122 source repair changes Desktop shortcuts only; Android and external contracts are unchanged. +Beta 16/code 17 remains prepared from `2723845e`, uninstalled; beta 10/code 11 is last installed. +Phone testing stays paused at **10 Pass / 22 Partial / 32 Pending**. The later tmux/SSH and +GPU follow-up below keeps its own source bindings; other-platform runtime remains unverified. +A105/A106 live checks need a usable provider after HTTP 401; +A25/A93 need hosted-backend maintainers. Resume Pixel checks when authorized. No PR is opened, +and A68 remains deferred until requested PR preparation. + +## Tmux, SSH and GPU recovery follow-up (2026-10-05) + +The fresh full Linux Desktop build at `6bfa5412` passes two separate eight-case matrices: +Canvas/Board modal × soft/hard wrap × inner mouse reporting off/SGR. Each matrix delivers +52 native mouse actions and eight complete URL opens through shipped xterm handlers. The local +matrix uses real shipped tmux; the remote matrix uses an actual SSH project, production +SshProjectManager/OpenSSH ControlMaster and remote `nodeterm-rmt` tmux. Exact pane, server and +client births survive every case. SSH retains its connection notice in history and calibrates the +actual viewport offset; the fixture does not erase it. Own session and SSH service cleanup pass. +These fixtures end with explicit app exit and supply no ordinary-Quit or SSH-reconnect proof. +Private proof: `desktop-tmux-matrix-20261005/` and `desktop-ssh-matrix-20261005/` below +`.nodeterm/android-beta-build-16/operation-verification-receipt/`. + +Two separate one-terminal GPU pilots at that same source pass delayed loss → readable stock DOM +→ fresh NVIDIA WebGL2 and quick original restoration → DOM → fresh WebGL2. Both retain the same +49×48 buffer/PTY and three trusted native input acknowledgements, with complete URL interaction +and glyph checks before/after. Quick restoration also exposes A123: the detached original +context remains live after the new renderer is granted. Chromium cap-pressure consequences are +an inference; no page-wide pressure failure was reproduced. + +A123 is source-fixed in `db83fb7d`: retire the captured original context after addon disposal and +before reporting context loss to the budget coordinator. Two new behavioral tests cover detached +canvases and retirement continuing after a throw; all 44 budget tests and 301 affected tests in +seven files pass with full TypeScript checking. Full 44-test control/restored helper runs catch +two exact assertion mutants. Three separate fresh full Desktop builds at `db83fb7d` verify the +actual restore hook: both fixed controls release the original context before fresh regrant, while +the compiled sole-call-removal mutant fails only `GPU_RETIRED_CONTEXT_NOT_LOST` after all three +native input acknowledgements and both complete URL controls pass. A capture-phase native event +witnesses the genuine restoration before production retires it; a passive second-loss event and +DOM/fresh/final checks verify retirement. Fixture probes retain the original context for observation; +no forced GC or second fixture loss supplies a pass. These are one-terminal Linux Canvas pilots, +with explicit fixture app exit rather than ordinary Quit. Private proof: `gpu-context-recovery-20261005/` +for historical recovery and `a123-context-retirement-20261005/` for the new source/compiled mutants. +The older 6bfa and A122 source/build bindings remain separate. + +**Additional Server verification (`fa259f6e`).** Three fresh genuine Linux Node Server/browser +Canvas soft/none runs pass fixed control / sole-call-removal mutant / restored confirmation +with natural fixture exits 0 / 3 / 0. Each authenticates through the shipped HTTP login and WS +bridge, without Desktop preload, and retains the same plain NodePTY, producer, 49×48 +buffer and geometry. Three trusted native input ACKs and two complete URL/glyph controls complete before +the retirement assertion. Fixed runs witness genuine original restoration, production loss of that retired +context, stock DOM fallback and fresh NVIDIA rendering; the calibrated mutant fails only +`GPU_RETIRED_CONTEXT_NOT_LOST`. Owned Server SIGTERM closes it gracefully with zero and removes +its PTY/producer; fixture app exit supplies no ordinary-Quit proof. This is one visible Server +Canvas pilot, not a Server GPU matrix or pressure test. + +**Additional SSH reconnect verification (`fa259f6e`).** One Linux Desktop Canvas soft/none +pilot passes after a single controlled SIGKILL of the exact product ControlMaster. Actual SSH +exit 255 triggers shipped automatic recovery; a separate strictly pinned read-only observer +verifies the same remote tmux pane and server, session-created identity, full history and producer across +the gap and recovery. A new master, local PTY and remote attachment are required. Two trusted +native `g` ACKs and complete URL hover/plain-click/Ctrl-click/leave checks pass with one producer +paint. Exact owned session, observer, master and daemon cleanup passes; explicit fixture app exit +provides no ordinary-Quit proof. Observer reads may affect timing, so this proves no performance +or backoff guarantee. Broader SSH reconnect matrices remain pending; this pilot supplies no +Modal, Server, Android or PAM enrollment proof. +Earlier V2 nonce admission and V3 service-process failures remain fixture negatives; V3's outage +state was not established. Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-ssh-reconnect-20261005/`. + +**Derived Desktop compositor evidence (`db83fb7d`).** A separate hash/CRC-checked analysis of +three existing positive-run `capturePage` PNGs compares an uncovered repeated `A` cell and a blank +cell with the actual framebuffer RGB references. All three match 152/152 pixels in each cell; +42 glyph pixels differ from the blank background. The Sessions-covered top-left cell is excluded, +not a product failure. Recorded geometry establishes 1:1 capture/CSS/GL scales at zoom 1; +a direct device-pixel-ratio query was not recorded. This adds a single-run compositor cell +correlation without rerunning the product or relabelling its old receipts. It does not establish +synchronized frames, whole-frame or physical-display output, or link-underline compositing. + +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/a123-server-context-retirement-20261005/` +contains **80 evidence/recipe files plus its manifest**. Server runtime and historical Desktop +cell derivation remain separate; the original Desktop receipts are unchanged. + +Android and external contracts are unchanged. Beta 16/code 17 remains prepared from `2723845e`, +uninstalled; beta 10/code 11 is last installed. Phone testing remains paused at **10 Pass / +22 Partial / 32 Pending**. Repeated/page-wide GPU pressure, shared glyph eviction, synchronized/ +full-frame compositor or physical-display output, broader SSH reconnect, macOS/Windows and the remaining +Pixel checklist are unverified. Live +A105/A106 needs usable provider authentication; A25/A93 belong to the hosted-backend maintainers. +A68 is source-fixed for branch readiness; no PR has been opened. + +**A68 workflow readiness (2026-10-05).** Source fix `7e91785c` limits Android +push runs to `main` and retains pull-request path filters, without `merge_group`. Feature-branch +pushes no longer duplicate PR checks or automatically prepare beta inputs. Manual +`workflow_dispatch` with `prepare_beta=true` runs all five Android jobs; beta version overrides +and **Private beta checks** are confined to that opt-in. The registered workflow `366764274` +accepts API/CLI dispatch on the selected ref; verify the run's exact head before using its results. +Pre-cutover control [run `37260849252`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37260849252) +was dispatched at `c371860f` with `prepare_beta=true`: all five jobs passed. +That control establishes dispatch admission, not verification of the later A68 source fix. +All 13 focused trigger/path methods pass. All eight actual workflow mutations fail the real +configuration test, with all 13 methods passing before and after restoration. The full +949-method/98-suite protocol gate and offline app compilation are required before push. +After push, exact-head CI is required through `workflow_dispatch` with `prepare_beta=true`; +the verified run and exact head are recorded with the source exports. This closes the source +finding; no PR is opened by this checkpoint. APK/client bytes, private signing, phone pause +and **10 Pass / 22 Partial / 32 Pending** are unchanged. + +## Pixel 7a beta-16 check and A124 host repair (2026-10-05) + +A fresh beta 16/code 17 installation on the separate Pixel 7a passes installed APK hash, +signature and non-debuggable checks. Normal manual explicit-profile, high-port SSH and bounded +existing-terminal input/cwd/Unicode history/held-drag/coast/new-touch-stop checks pass. +The original `15dd1341` **New session → Start** attempt retained default Claude because its +nonclickable label did not select the shell; shared shell validation exposed A124 before CLI lookup. +The repaired wrong-label attempt's unavailable-Claude-CLI refusal is expected, not a new finding. +Actual shell-radio selection on frozen Desktop `40731381` creates and registers `/bin/bash`. +Phone input executes once in the expected cwd; viewer reopen and an Android-process restart with +saved SSH reconnect retain the same pane/marker and leave the other original terminal untouched. + +Host-retained Find locates two offscreen matching lines in 155 retained lines after 150 additional +output lines; native visible-screen capture independently excludes the old marker. History-line +clipboard paste matches. Local Copy-sheet search correctly has no offscreen-marker match; its +public URL offer and URL clipboard roundtrip pass. External browser opening is unverified. +Both QA profiles are removed through the app, which returns to the initial Computers/Add screen; +beta 16 and global identity remain. The repaired V5 host stops cleanly: app/sshd exit, closed +listeners and generated private-auth cleanup pass. The earlier host's source-binding failure stays +historical and separate. + +At `40731381`, offline protocol passes **949 methods / 98 suites** with zero failures/errors/skips; +38 affected Vitest tests, full TypeScript and three assertion-caught isolated guard mutants pass +(five platform Vitest cases skip on Linux). The frozen full Desktop build passes in 29.3 seconds. +First-push [Android run `37375870878`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37375870878) +passes all five jobs against exact head `40731381c646ccd3425af597b116b5371daf0a63`. +See [the verification notes](../docs/android.md#what-is-verified-and-how) and [audit A124](../docs/android-audit-2026-09.md#a124) for precise evidence and scope. Private physical proof: +`.nodeterm/android-pixel7a-device-y0ia4hm_/physical-repair-result.json`. +The original Pixel 10 Pro beta-10 ledger stays paused at **10 Pass / 22 Partial / 32 Pending**. +No wire/file contract changes; @eneskirca can note the shared host benefit for iOS. + +Next: continue the remaining background/lifecycle/outage, notification/answer/permission and +live-provider device matrix using fresh owned fixtures. Single-case success does not verify +managed End, power loss, original Pixel update/migration, QR/relay/cellular routes, FPS or all +64 checklist items. A25/A93 backend dependencies and other-platform checks remain separate. + +## Native custom wheel checks (2026-10-06) + +A86 now has a real SSH/tmux regression for both copy key modes, custom host gains, chunked distance +and queued reversals. All eight settled native positions and control/restored runs pass; three +transport regressions fail the intended distance assertion. Original bindings/options are restored. +See [the native receipt](../docs/android.md#native-custom-wheel-checks-2026-10-06). +This adds test coverage without changing beta 16, production transport or iOS wire/file contracts. +Phone custom-binding/lifecycle/FPS, the broader release matrix and A25/A93 backend work remain open. +The original Pixel checklist remains paused at **10 Pass / 22 Partial / 32 Pending**. + +## Desktop card reconnect repair (2026-10-06) + +A125 is source-fixed in `415dae9b`; A126 is committed in `5b7286b3`. +A125 replaces only the lost open card viewer after the coordinator succeeds and passes the +owning project through both boards. The card stays open, retains persistKey/requireRemote and +never replays an initial, pending or agent launch command. Its 13 new cases/100 affected tests +and six assertion-caught mutants retain their separate control/restored evidence. +A126 chooses one live automatic responder per exact `api.pty`/actual session in this renderer, +while preserving genuine input/focus and deterministic promotion. Its 16 new cases comprise nine +installed-xterm checks, one fallback shim and six fake-rendering mounted Modal cases with real +helper/LocalTransport/coordinator. +**294 unique affected tests / 17 files** and full incremental TypeScript pass. **15 isolated +mutants (six A125 + nine A126)** are caught by assertions; each fix's control/restored runs pass. +Forced offline checks at `5b7286b3` execute **950 tests / 98 suites**, zero failures/errors/skips, +and both protocol/app Kotlin compile tasks. These are disposable QA hosts; no new APK or regular +Desktop installation is included. Publication requires fresh offline gates and all five Android +CI jobs for the exact published HEAD; its private bundle and final report carry that run's result. + +Eight actual Linux Desktop **Canvas/Modal × soft/hard wraps × inner none/SGR mouse** cases +pass at `5b7286b3`. Each loses one birth-pinned product ControlMaster, creates a new master/ +selected local viewer and retains its remote pane/server/producer/cwd and complete same-grid history before, +during, after and at final observation. Both trusted actual textarea `g` inputs are acknowledged +on the old/new PTY: **16 ACKs**, **zero unexpected bytes**, one producer paint per case and +**16 complete URL activations**. Native hover/leave and exact owned cleanup pass. Modal cases +use `80x24`; Canvas cases use `49x48`, with each history comparison bound to its own grid/case. +Private native aggregate: `~/.cache/nodeterm-android-work/a125-a126-native-matrix-ww8e8nwo/root-acceptance.json`, +SHA `d8a5366b83d4c4b3ada9a1112521f762905ce3b835be4a32983ce9150f4f391a`. +It binds all eight receipt/runtime hashes, geometry/history/timing and exact cleanup. + +The historical `11e22453` stale-Modal negative and first `415dae9b` control retain their original +provenance. The latter creates replacement viewers but records 18 unexpected DA1/DA2 bytes after +ACK 1; ACK 2 is not attempted. Its unclassified wrapper/runtime exits 1/2 remain separate from the +later A126 diagnosis. Those failed controls are not rewritten as repaired-source positives. +See [audit A125](../docs/android-audit-2026-09.md#a125) and [audit A126](../docs/android-audit-2026-09.md#a126) for exact evidence. + +**Two-host follow-up (2026-10-06).** One Linux Desktop soft/none case at `59e4c93e` passes in +27.39 seconds: an ordinary quiet B park/adopt cycle, then inactive B's continuously open global +card reconnects while A remains active and unchanged. Three native `g` ACKs, two complete URL +activations, zero unexpected input, one paint per role, same-grid retained history and exact owned +cleanup pass. Carried SID provenance is primary create/native Canvas `g1` plus unchanged writer/ +subscriptions/client; no adopted Canvas-input/current SID-ref result is claimed. See [the bounded two-host follow-up](../docs/android.md#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06) +for exact source/proof hashes and the remaining scope; no new repair or product test count is added. + +Actual Canvas mounting and selected-view native keyboard recovery pass in the eight-case matrix; +one additional two-host soft/none case verifies a quiet park/adopt cycle and inactive global-card +recovery with the active host unchanged. See [the bounded two-host follow-up](../docs/android.md#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06). Remaining Android release acceptance needs a +reachable intended phone for lifecycle/outage, answer/notification/permission and live-provider +checks; A25/A93 need hosted-backend maintainers. Further host coverage includes streaming/expiry/ +eviction while parked, repeated or broader outage/backoff, Server/cross-window response ownership, +GPU pressure/full-frame display output and macOS/Windows. The synthetic key-only hosts do not +establish real-user PAM, power-loss, agent-launch or ordinary Quit behavior. Unsupported private +xterm shapes warn and retain input but may duplicate replies; legacy onBinary is unchanged. +No Android wire/file or iOS client change is required. The original Pixel 10 Pro ledger remains +paused at **10 Pass / 22 Partial / 32 Pending**. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000000..7261e99e23 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,120 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +// The local beta packager verifies these against the APK before signing it. Normal debug and unsigned +// release builds keep their existing version when no override is supplied. +val androidVersionCode = providers.environmentVariable("NODETERM_ANDROID_VERSION_CODE").orNull?.takeIf { it.isNotEmpty() }?.let { + require(Regex("[1-9][0-9]{0,9}").matches(it)) { "NODETERM_ANDROID_VERSION_CODE must be a positive integer." } + val code = it.toLongOrNull() + require(code != null && code <= 2_100_000_000) { "NODETERM_ANDROID_VERSION_CODE exceeds Android's version-code limit." } + code.toInt() +} ?: 1 +val androidVersionName = providers.environmentVariable("NODETERM_ANDROID_VERSION_NAME").orNull?.takeIf { it.isNotEmpty() }?.let { + require(Regex("[0-9]+\\.[0-9]+\\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?").matches(it) && it.length <= 100) { + "NODETERM_ANDROID_VERSION_NAME must be a version such as 0.1.0 or 0.1.0-beta.1." + } + it +} ?: "0.1.0" + +android { + namespace = "dev.nodeterm.android" + compileSdk = 35 + + defaultConfig { + applicationId = "dev.nodeterm.android" + minSdk = 26 + targetSdk = 35 + versionCode = androidVersionCode + versionName = androidVersionName + } + + signingConfigs { + // A PUBLIC debug key, committed on purpose (audit A10): without it every CI run signs with a + // fresh runner-generated key, so installing the next APK over the last one fails and the only + // way forward is uninstalling, which wipes every pairing. Its password is the Android default. + // + // Public means ANYONE can sign an APK that installs as an update over a debug build and so + // inherits its data and its Keystore-held secrets (the SSH key paired computers trust, the relay + // identity). Install debug APKs only from this repo's CI or your own build. A release build + // must never use this key; there is no release signing config yet. + getByName("debug") { + storeFile = file("debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + + buildTypes { + debug { + signingConfig = signingConfigs.getByName("debug") + } + release { + // R8 (shrink + obfuscate) runs for release only; debug stays unminified and is the APK that + // is distributed. CI builds this unsigned release on every change (audit A37), so a missing + // -dontwarn fails CI (R8 reports the missing class), and tools/check-r8-output.sh checks that + // the keeps it names matched. A keep that NEW reflection needs is not detected: R8 renames or + // drops such code silently. None of it proves the reflection-loaded code (BouncyCastle's + // provider tables, the WebView bridge, the WorkManager worker) works on a device, which + // nobody has run a release build on. There is no release signing config yet. + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + packaging { + resources { + // bcprov + bcpkix both ship this multi-release manifest; sshj pulls both in. + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE.md", + "META-INF/LICENSE-notice.md", + "META-INF/INDEX.LIST" + ) + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation("dev.nodeterm:protocol:0.1.0") + + val composeBom = platform("androidx.compose:compose-bom:2025.06.00") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-core") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("androidx.core:core-ktx:1.16.0") + implementation("androidx.activity:activity-compose:1.10.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.1") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.1") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1") + implementation("androidx.lifecycle:lifecycle-process:2.9.1") + implementation("androidx.work:work-runtime-ktx:2.10.1") + + // QR pairing (ZXing; no Play Services dependency). + implementation("com.journeyapps:zxing-android-embedded:4.3.0") + // sshj needs the FULL BouncyCastle provider on Android (the platform's "BC" is a stripped copy). + implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") +} diff --git a/android/app/debug.keystore b/android/app/debug.keystore new file mode 100644 index 0000000000..fde81f220a Binary files /dev/null and b/android/app/debug.keystore differ diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000000..7c9d35b5e8 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,94 @@ +# R8 rules for the RELEASE build type (audit A37). Debug is not minified, and the debug APK is the +# one distributed. CI builds an unsigned release APK (`:app:assembleRelease`) on every change, so R8 +# runs against these rules: a missing -dontwarn fails CI instead of the first real release. A missing +# -keep does not fail R8: it renames or drops code it cannot see used and reports nothing. So code +# reached by a NEW name (a new Class.forName, a class a new library loads from a string) needs its own +# keep here AND a line in tools/check-r8-output.sh, or CI stays green without it. +# +# What is verified, and by what: +# - R8 finishing in CI: the build accepts these rules (no missing-class errors). Nothing more. +# - tools/check-r8-output.sh (CI, after that build): the keeps it names MATCHED. The bridge methods, +# the worker, the BouncyCastle provider tables and an exception class are in R8's seeds and keep +# their names in its mapping. A keep whose class was renamed matches nothing, silently. It checks +# nothing about the sshj and EdDSA keeps, nor about any target it does not name. +# - R8RulesTest (android/protocol, runs without AGP): re-derives the missing classes from the jars +# the protocol module ships to the app and checks every one is covered below or by the library's +# own consumer rules, and pins the bridge, worker and exception-name keeps against the app's +# sources (a new WorkManager worker without its keep fails it). +# - NOT verified: that the reflection-loaded code works on a device. The release APK has never run +# on one. R8 passing proves the build, not the runtime. +# +# The reflection facts below were read from the jars with javap/jdeps (sshj 0.39.0, eddsa 0.3.0, +# bcprov/bcpkix/bcutil 1.78.1, OkHttp 4.12.0, slf4j-api 2.0.13, zxing-android-embedded 4.3.0, zxing +# core 3.4.1). Classes of the APP loaded by name: BouncyCastle's (by BouncyCastle, the JCA and sshj's +# SecurityUtils), and the app classes below. OkHttp's reflection targets platform classes R8 never +# renames (com.android.org.conscrypt, dalvik.system.CloseGuard); slf4j's ServiceLoader finds no binding +# on Android either way and falls back to its no-op logger; eddsa and zxing do not reflect. What rests +# on a library's own consumer rules instead (androidx, Compose, WorkManager and androidx.startup) is +# inferred: those AARs are on Google Maven, which the sandbox these rules were written in cannot read. + +# ---- Runtime keeps: code reached by NAME, which R8's tracing cannot see --------------------------- + +# The terminal page (assets/terminal/terminal.js) calls TerminalController.Bridge's methods by name +# through WebView.addJavascriptInterface, and Android exposes only methods that still carry the +# @JavascriptInterface annotation. Without this R8 renames or drops onReady/onInput/onResize/…, and the +# terminal gets no keystrokes and never sizes its pty. AGP's default file carries the same rule; it is +# restated so the bridge does not hang on which default file is in use. +-keepattributes RuntimeVisibleAnnotations +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; +} + +# WorkManager instantiates the background check from the class NAME it stored in its database, through +# the (Context, WorkerParameters) constructor. The periodic work is enqueued with KEEP, so a name that +# changed between two releases would leave the stored work pointing at a class that no longer exists, +# and notifications would stop with nothing on screen saying why. work-runtime ships a rule for every +# ListenableWorker (inferred, see above); this one does not depend on it. +-keep class dev.nodeterm.android.notify.InboxWorker { + public (android.content.Context, androidx.work.WorkerParameters); +} +# The same for the worker that sends an answer given from a notification (audit A25). Its work is +# one-time, but it can still be pending in WorkManager's database across an update (an expedited job +# out of quota runs as ordinary work, later). Its receiver is declared in the manifest, whose +# components AGP keeps by itself. +-keep class dev.nodeterm.android.notify.InboxActionWorker { + public (android.content.Context, androidx.work.WorkerParameters); +} + +# BouncyCastle is a JCA provider, and that is reflection end to end: BouncyCastleProvider loads its +# algorithm tables by name ("org.bouncycastle.jcajce.provider.symmetric." + "AES" + "$Mappings"), and +# the JCA instantiates every Cipher / KeyAgreement / Signature SPI from a class-name string. sshj asks +# the "BC" provider for its ciphers and key exchanges, and its SecurityUtils also loads +# org.bouncycastle.jce.provider.BouncyCastleProvider with Class.forName. Which SPIs a connection needs +# is decided at runtime by what the computer offers, so all of it is kept. +-keep class org.bouncycastle.** { *; } + +# sshj itself reflects only in SecurityUtils (above). It is kept whole anyway, BOTH of its package roots +# (the old rule named only net.schmizz.sshj), so the SSH transport the protocol tests run on the JVM is +# the one that ships; the cost is APK size, not behaviour. Same for EdDSA: no reflection and no +# provider registration (the phone's key is built with it directly), 33 classes. +-keep class net.schmizz.sshj.** { *; } +-keep class com.hierynomus.sshj.** { *; } +-keep class net.i2p.crypto.eddsa.** { *; } + +# User-facing error text falls back to the exception's class name when it has no message +# (`e.message ?: e.javaClass.simpleName`: SshHostConnection, SshFallback, PairingClient, RelayApi, +# OkHttpRelayTransport, SecretStoreCore, ConnectionManager). Keep the names so it does not read "(a)". +-keepnames class * extends java.lang.Throwable + +# ---- Missing classes: referenced from the jars the app ships, absent from android.jar --------------- +# R8 in AGP 8 fails the build on each of these. Found with jdeps over the runtime jars; each is reached +# only by code the app never runs on a phone. The other missing references (OkHttp's Conscrypt / +# OpenJSSE / BouncyCastle-JSSE platforms, kotlinx-coroutines' java.lang.instrument and sun.misc agent, +# java.lang.ClassValue in coroutines and serialization) are covered by the consumer rules those jars +# ship. R8RulesTest re-derives the list; slf4j-api 2.x resolves completely, so it needs no rule. + +# sshj: GSSAPI (Kerberos) user authentication, AuthGssApiWithMic and SSHClient.authGssApiWithMic. +-dontwarn org.ietf.jgss.** +-dontwarn javax.security.auth.login.LoginContext +# eddsa: EdDSAEngine.engineInitVerify re-encodes an OpenJDK X509Key, a branch taken only for a key that +# is not an EdDSAPublicKey; sshj's Ed25519 keys (Ed25519PublicKey) always are. +-dontwarn sun.security.x509.X509Key +# BouncyCastle: LDAP certificate stores and the DANE fetcher (JNDI). OkHttp's consumer rules happen to +# -dontwarn every class under org.bouncycastle.** as the referrer too; this does not lean on that. +-dontwarn javax.naming.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..1cc087aa26 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/terminal/LICENSE-xterm.txt b/android/app/src/main/assets/terminal/LICENSE-xterm.txt new file mode 100644 index 0000000000..4472336c9f --- /dev/null +++ b/android/app/src/main/assets/terminal/LICENSE-xterm.txt @@ -0,0 +1,21 @@ +Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) +Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com) +Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/android/app/src/main/assets/terminal/addon-fit.js b/android/app/src/main/assets/terminal/addon-fit.js new file mode 100644 index 0000000000..9b25be35bb --- /dev/null +++ b/android/app/src/main/assets/terminal/addon-fit.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})())); +//# sourceMappingURL=addon-fit.js.map \ No newline at end of file diff --git a/android/app/src/main/assets/terminal/index.html b/android/app/src/main/assets/terminal/index.html new file mode 100644 index 0000000000..635318a0e0 --- /dev/null +++ b/android/app/src/main/assets/terminal/index.html @@ -0,0 +1,28 @@ + + + + + + + + + +
+ + + + + diff --git a/android/app/src/main/assets/terminal/terminal.js b/android/app/src/main/assets/terminal/terminal.js new file mode 100644 index 0000000000..a1305a7f47 --- /dev/null +++ b/android/app/src/main/assets/terminal/terminal.js @@ -0,0 +1,870 @@ +// The phone's terminal: xterm.js (the desktop's emulator) as a RENDERER for a tmux client that +// lives on the computer. Android talks to it through two narrow channels: +// Android → page: window.nt.* functions, called with base64 so no byte ever needs JS escaping; +// page → Android: window.NodetermBridge (addJavascriptInterface), strings only. +// tmux owns scrolling (its mouse is on and the pane is on the alternate screen), so a vertical +// swipe becomes wheel notches the HOST writes into the pane — exactly what the relay's +// `pty.scroll` does — and xterm keeps no scrollback of its own worth scrolling. +// tmux's mouse also means xterm's own selection never runs (it is off while the pane reports the +// mouse), so links and copying are this page's job (audit A32): a tap on a URL offers to open it, +// and the Copy sheet (`nt.copySheet`) hands the buffer's lines and links to the app. +(function () { + 'use strict' + var bridge = window.NodetermBridge + var fontSize = 13 + var term = new Terminal({ + fontSize: fontSize, + fontFamily: 'monospace', + cursorBlink: true, + scrollback: 1000, + allowProposedApi: true, + macOptionIsMeta: true, + theme: { background: '#000000', foreground: '#e6e6e6', cursor: '#e6e6e6' } + }) + var fit = new FitAddon.FitAddon() + term.loadAddon(fit) + var host = document.getElementById('term') + term.open(host) + // Retained host history is inert text in its own stable layer. Live xterm remains mounted and + // parses every byte behind it, preserving parser state, reports, modes and the live buffer. + var historyPage = null + var displayEpoch = 0 + var historyLayer = document.createElement('div') + historyLayer.id = 'history-view' + historyLayer.hidden = true + var historyRows = document.createElement('div') + historyRows.id = 'history-rows' + var historyTools = document.createElement('div') + historyTools.id = 'history-tools' + var historyNote = document.createElement('span') + historyNote.id = 'history-note' + var liveButton = document.createElement('button') + liveButton.textContent = 'Live' + historyTools.appendChild(historyNote) + historyTools.appendChild(liveButton) + historyLayer.appendChild(historyRows) + historyLayer.appendChild(historyTools) + host.appendChild(historyLayer) + function closeScrollView(invalidate, expectedEpoch) { + if (expectedEpoch !== undefined && expectedEpoch !== displayEpoch) return + if (invalidate !== false) displayEpoch++ + historyPage = null + historyLayer.hidden = true + historyRows.replaceChildren() + } + function cancelInputScroll() { cancelScroll(); closeScrollView() } + liveButton.addEventListener('touchstart', function (e) { cancelScroll(); e.stopPropagation() }) + liveButton.addEventListener('click', function (e) { + e.stopPropagation() + cancelInputScroll() + if (typeof bridge.onHistoryClose === 'function') bridge.onHistoryClose() + }) + historyLayer.addEventListener('click', function (e) { + var cell = cellAt(e.clientX, e.clientY) + var url = cell ? linkAt(cell.row, cell.col) : null + if (url) { e.preventDefault(); openUrl(url) } + }) + function showScrollView(b64, epoch) { + if (!scrollActive || epoch !== displayEpoch) return + var page + try { page = JSON.parse(b64ToText(b64)) } catch (e) { return } + if (!page || page.status !== 'history' || !Array.isArray(page.rows) || !page.rows.length || page.rows.length > 200) return + historyPage = page + var rowHeight = measuredScrollStep() + historyRows.style.fontFamily = 'monospace' + historyRows.style.fontSize = fontSize + 'px' + historyRows.style.lineHeight = rowHeight + 'px' + historyRows.replaceChildren() + page.rows.forEach(function (row) { + var line = document.createElement('div') + line.textContent = row.text || ' ' + line.style.height = rowHeight + 'px' + historyRows.appendChild(line) + }) + historyNote.textContent = page.olderTruncated && !page.hasOlder ? 'Earlier output was dropped' : 'History' + historyLayer.hidden = false + // Do not cancel/restart the touch or animation here: a response may arrive mid-drag. + } + + + // A closed SSH tmux painter restores its outer terminal after clearing the alternate screen. + // Keep the last visible pane separately; never suppress control sequences in the live parser. + var endViewer = null + var viewerEpoch = 0 + var closedPage = null + var closedLayer = document.createElement('div') + closedLayer.id = 'closed-view' + closedLayer.hidden = true + closedLayer.style.cssText = 'position:absolute;inset:0;padding:2px 2px 2px 4px;background:#000;color:#e6e6e6;overflow:hidden;touch-action:none;z-index:3' + host.appendChild(closedLayer) + function invalidatePendingClosedCapture() { + userInput = false + closedTouch = null + if (endViewer) { + endViewer.revision++ + endViewer.candidate = null + endViewer.leftAlternate = false + endViewer.ending = false + } + } + function clearClosedCapture() { + viewerEpoch++ + invalidatePendingClosedCapture() + closedPage = null + closedLayer.hidden = true + closedLayer.replaceChildren() + } + function beginViewer(token, sshTmux) { + clearClosedCapture() + var viewer = { token: token, sshTmux: sshTmux === true, revision: 0, + capturing: false, enteredAlternate: false, leftAlternate: false, candidate: null, ending: false } + endViewer = viewer + // Previously dispatched writes parse before the new viewer may capture anything. + term.write('', function () { if (endViewer === viewer) viewer.capturing = true }) + } + function retireViewer() { + clearClosedCapture() + endViewer = null + } + function captureAlternate(viewer) { + // Refuse an oversized frame, including blank frames, rather than keeping an older candidate. + viewer.candidate = null + if (term.rows > 200 || term.cols > 1000 || term.rows * term.cols > 20000) return + var buf = term.buffer.active + var rows = [] + var uris = Object.create(null), ranges = 0, budget = 0 + var encoder = new TextEncoder() + for (var r = 0; r < term.rows; r++) { + var line = buf.getLine(buf.viewportY + r) + var text = line ? line.translateToString(false) : '' + budget += encoder.encode(text).length + if (budget > 256 * 1024) return + var links = [] + for (var c = 0; line && c < term.cols; c++) { + var cell = line.getCell(c) + var id = cell && cell.extended ? cell.extended.urlId : 0 + if (id && !Object.prototype.hasOwnProperty.call(uris, id)) { + var data = term._core._oscLinkService.getLinkData(id) + if (Object.keys(uris).length >= 128 || (data && data.uri && data.uri.length > 4096)) return + uris[id] = oscLinkUri(id) + } + var href = id ? uris[id] : null + var last = links[links.length - 1] + if (href && last && last.url === href && last.end === c) last.end++ + else if (href) { + if (++ranges > 512) return + budget += encoder.encode(href).length + 64 + if (budget > 256 * 1024) return + links.push({ start: c, end: c + 1, url: href }) + } + } + rows.push({ text: text, isWrapped: !!(line && line.isWrapped), section: 'alternate', links: links }) + } + var candidate = { cols: term.cols, rows: rows, copy: snapshot(true) } + if (encoder.encode(JSON.stringify(candidate)).length <= 1024 * 1024) viewer.candidate = candidate + } + term.parser.registerCsiHandler({ final: 'J' }, function (params) { + var viewer = endViewer + if (params[0] === 2 && viewer && viewer.sshTmux && viewer.capturing && viewer.enteredAlternate && term.buffer.active.type === 'alternate') captureAlternate(viewer) + return false + }) + ;['h', 'l'].forEach(function (final) { + term.parser.registerCsiHandler({ prefix: '?', final: final }, function (params) { + var viewer = endViewer + if (viewer && viewer.sshTmux && viewer.capturing && params.some(function (p) { return p === 47 || p === 1047 || p === 1049 })) { + if (final === 'h') { viewer.enteredAlternate = true; viewer.leftAlternate = false; viewer.candidate = null } + else if (viewer.enteredAlternate && term.buffer.active.type === 'alternate') viewer.leftAlternate = true + } + return false + }) + }) + term.onWriteParsed(function () { + var viewer = endViewer + // A real live repaint supersedes the pre-clear frame; ED2 alone leaves it pending for teardown. + if (viewer && viewer.candidate && term.buffer.active.type === 'alternate') { + for (var r = 0; r < term.buffer.active.length; r++) { + if (term.buffer.active.getLine(r).translateToString(true)) { viewer.candidate = null; break } + } + } + }) + function endViewerDisplay(token) { + var viewer = endViewer + if (!viewer || viewer.token !== token || !viewer.sshTmux || viewer.ending) return + viewer.ending = true + var revision = viewer.revision + term.write('', function () { + if (endViewer !== viewer || viewer.revision !== revision || !viewer.ending || !viewer.leftAlternate || term.buffer.active.type !== 'normal' || !viewer.candidate) return + closedPage = viewer.candidate + scrollActive = false + cancelScroll() + closeScrollView() + renderClosedPage() + }) + } + function renderClosedPage() { + if (!closedPage) return + var rect = term.element.querySelector('.xterm-screen').getBoundingClientRect() + var cellWidth = rect.width / term.cols, cellHeight = rect.height / term.rows + closedPage.metrics = { left: rect.left, top: rect.top, width: rect.width, height: rect.height, + cellWidth: cellWidth, cellHeight: cellHeight } + closedLayer.replaceChildren() + closedPage.rows.forEach(function (row) { + var line = document.createElement('div') + line.textContent = row.text || ' ' + line.style.cssText = 'pointer-events:none;white-space:pre;font-family:monospace' + line.style.fontSize = fontSize + 'px' + line.style.height = line.style.lineHeight = cellHeight + 'px' + closedLayer.appendChild(line) + }) + closedLayer.hidden = false + } + function closedGeometryChanged() { + // Resize invalidates a pending EOF capture, but keeps a settled pane and its Copy snapshot. + invalidatePendingClosedCapture() + renderClosedPage() + } + var closedTouch = null + function closedLinkAt(x, y) { + var cell = cellAt(x, y) + var url = cell ? linkAt(cell.row, cell.col) : null + if (url) openUrl(url) + } + closedLayer.addEventListener('touchstart', function (e) { + e.stopPropagation() + closedTouch = e.touches.length === 1 ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null + }, { passive: true }) + closedLayer.addEventListener('touchmove', function (e) { + e.stopPropagation(); e.preventDefault() + if (closedTouch && (e.touches.length !== 1 || Math.abs(e.touches[0].clientX - closedTouch.x) > TAP_SLOP || Math.abs(e.touches[0].clientY - closedTouch.y) > TAP_SLOP)) closedTouch = null + }, { passive: false }) + closedLayer.addEventListener('touchend', function (e) { + e.stopPropagation(); e.preventDefault() + var touch = closedTouch + closedTouch = null + var end = e.changedTouches && e.changedTouches[0] + if (closedPage && touch && end && e.touches.length === 0 && Math.abs(end.clientX - touch.x) <= TAP_SLOP && Math.abs(end.clientY - touch.y) <= TAP_SLOP) closedLinkAt(touch.x, touch.y) + }, { passive: false }) + closedLayer.addEventListener('touchcancel', function (e) { e.stopPropagation(); closedTouch = null }, { passive: true }) + closedLayer.addEventListener('click', function (e) { + e.stopPropagation(); e.preventDefault() + if (closedPage) closedLinkAt(e.clientX, e.clientY) + }) + window.addEventListener('pagehide', retireViewer) + + var lastCols = 0 + var lastRows = 0 + function doFit(force) { + try { fit.fit() } catch (e) { /* not laid out yet */ } + if (force || term.cols !== lastCols || term.rows !== lastRows) { + closedGeometryChanged() + lastCols = term.cols + lastRows = term.rows + bridge.onResize(term.cols, term.rows) + } + } + window.addEventListener('resize', function () { doFit(false); closedGeometryChanged() }) + + function b64ToBytes(b64) { + var bin = atob(b64) + var out = new Uint8Array(bin.length) + for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) + return out + } + function b64ToText(b64) { + return new TextDecoder('utf-8').decode(b64ToBytes(b64)) + } + + // onData also carries terminal replies and focus/mouse reports. Those must not cancel a swipe + // or discard native queued scrolls. In the pinned xterm 5.5 bundle CoreService fires onUserInput + // immediately BEFORE onData for keys, paste and IME input, but also for SGR mouse reports. + // Consume the flag once and exclude that complete mouse-report shape. Never cancel in the flag + // listener: a mouse report sets it too. Keep a guarded fallback if xterm's internal API changes. + var userInput = false + var hasInputOrigin = false + function mouseReport(data) { return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data) } + function generatedReport(data) { + return mouseReport(data) || /^\x1b\[[IO]$/.test(data) || + /^\x1b\[(?:[?>][\d;]*c|\??\d+;\d+R|0n|\??\d+;\d+\$y|[468];\d+;\d+t)$/.test(data) || + /^\x1b(?:\][\d;]+;[^\x1b]*|P[01]\$r[^\x1b]*)\x1b\\$/.test(data) + } + try { + var service = term._core && term._core.coreService + if (service && typeof service.onUserInput === 'function') { + service.onUserInput(function () { userInput = true }) + hasInputOrigin = true + } + } catch (e) { /* a future bundle can still use the public key/DOM fallback below */ } + if (!hasInputOrigin) { + if (typeof term.onKey === 'function') term.onKey(function () { userInput = true }) + if (term.textarea && typeof term.textarea.addEventListener === 'function') { + ;['input', 'paste', 'compositionend'].forEach(function (type) { + term.textarea.addEventListener(type, function () { userInput = true }, true) + }) + } + } + term.onData(function (d) { + var fromUser = userInput + userInput = false + if (closedPage) return + if (mouseReport(d) || (!fromUser && (hasInputOrigin || generatedReport(d)))) bridge.onReport(d) + else { cancelInputScroll(); bridge.onInput(d) } + }) + // In this xterm bundle onBinary is used only by the legacy mouse encoding. + term.onBinary(function (d) { userInput = false; if (!closedPage) bridge.onReport(d) }) + + // Copy: tmux's copy-mode emits OSC 52 (set-clipboard on). The whole sequence goes to Kotlin's + // Osc52.parse, which mirrors the desktop's parseOsc52: the ';' is required, a read query ('?') is + // refused (write-only), and base64 and UTF-8 are decoded strictly. The one check made here is the + // size cap (audit A53): xterm accepts OSC payloads up to 10,000,000 characters, and a copy that + // big must neither cross the bridge nor reach the clipboard's binder call. The cap is Kotlin's + // own constant, read once over the bridge so there is one definition; Kotlin checks it again. + var copyLimit = bridge.copyLimit() + term.parser.registerOscHandler(52, function (data) { + var idx = data.indexOf(';') + if (idx < 0) return true + // The selection field before the ';' is a few letters ('c', 'p', 's0'…); a long one is not a + // clipboard write we understand, and it must not carry megabytes across the bridge either. + if (idx > 16) return true // = Osc52.MAX_SELECTION + if (data.length - idx - 1 > copyLimit) bridge.onCopyTooLarge() + else bridge.onCopy(data) + return true + }) + + // Links (audit A32). A URL in the pane's output is matched across the rows it wraps over, the + // desktop's way: the functions from matchUrlTokens to tokenRange are ported from + // src/renderer/terminal/file-links.ts and keep its names. tmux repaints, and an agent's fullscreen + // TUI paints, a long line as separate full-width rows with no wrap flag, so a long OAuth URL matched + // row by row would open only its first row's fragment. That is why @xterm/addon-web-links is not + // used: it joins only xterm's own soft wraps. OSC 8 links (a label with the URL hidden in the escape + // sequence; tmux passes them on because the desktop declares `hyperlinks`) go through + // term.options.linkHandler. Without one, xterm asks with confirm(), which a WebView without a + // WebChromeClient never shows, so the link did nothing. + // + // Every way in ends at openUrl, which hands the bridge only an http(s) URL, as the URL parser + // normalizes it. The app asks before it leaves for the browser ("Open ?") and checks again. + var URL_RE = /\bhttps?:\/\/[^\s"'`<>()[\]{}|\\^]+/gi + var TRAILING_PUNCT = /[.,;:!?'")\]}>]+$/ + /** Rows joined in each direction at most; a wrapped OAuth URL is about 7 rows at 80 columns. */ + var MAX_JOIN_ROWS = 32 + + /** The URL as the URL parser writes it, when it is http(s); null for anything else. */ + function httpHref(text) { + try { + var u = new URL(text) + return u.protocol === 'http:' || u.protocol === 'https:' ? u.href : null + } catch (e) { + return null + } + } + function openUrl(raw) { + var href = httpHref(raw) + if (href) bridge.openUrl(href) + } + + function matchUrlTokens(lineText) { + var out = [] + var re = new RegExp(URL_RE.source, 'gi') + var m + while ((m = re.exec(lineText)) !== null) { + var text = m[0].replace(TRAILING_PUNCT, '') + if (text.length < 8) continue // "http://x" is the shortest sane URL + if (!httpHref(text)) continue + out.push({ text: text, startIndex: m.index, url: text }) + } + return out + } + + function bufferView(liveOnly) { + var capturedPage = !liveOnly && (closedPage || historyPage) + if (capturedPage) return { + cols: capturedPage.cols, + history: true, + length: capturedPage.rows.length, + line: function (row) { + var line = capturedPage.rows[row] + return line ? { isWrapped: line.isWrapped, section: line.section, + text: function (trim) { return trim ? line.text.replace(/\s+$/, '') : line.text } } : undefined + } + } + var buf = term.buffer.active + return { + cols: term.cols, + length: buf.length, + line: function (row) { + var l = buf.getLine(row) + return l ? { isWrapped: l.isWrapped, text: function (trim) { return l.translateToString(trim) } } : undefined + } + } + } + + function historyCellWidth(text) { + var service = term._core && term._core.unicodeService + return service && typeof service.getStringCellWidth === 'function' ? service.getStringCellWidth(text) : text.length + } + function historyStringIndex(text, col) { + var service = term._core && term._core.unicodeService + if (!service || typeof service.wcwidth !== 'function') return col + var cell = 0 + var index = 0 + while (index < text.length) { + var code = text.codePointAt(index) + var width = service.wcwidth(code) + if (cell + width > col) return index + cell += width + index += code > 65535 ? 2 : 1 + } + return index + Math.max(0, col - cell) + } + + // Whether `row` runs into `row + 1`: the next row carries xterm's soft-wrap flag, or `row` is full + // to its last column and the next row starts at column 0 with a non-space (a repainted wrap). A + // heuristic: the buffer cannot tell a repainted wrap from text that exactly fills the row. + function continuesOnNextRow(view, row) { + var next = view.line(row + 1) + if (!next) return false + var current = view.line(row) + if (current && current.section && current.section !== next.section) return false + if (next.isWrapped) return true + var cur = view.line(row) + if (!cur) return false + var raw = cur.text(false) + if ((view.history ? historyCellWidth(raw) : raw.length) < view.cols || raw[raw.length - 1] === ' ') return false + var nextRaw = next.text(false) + return nextRaw.length > 0 && nextRaw[0] !== ' ' + } + + // The logical paragraph containing `row` (0-based). Every row that continues contributes exactly + // `cols` characters, so an index into `text` is the cell (startRow + idx / cols, idx % cols); the + // last row is right-trimmed. One departure from the desktop's: the walk up stops a row earlier, so + // the MAX_JOIN_ROWS rows joined downward always reach `row`. The desktop's could walk up all 32 and + // then join only the 32 above `row` (a tap there missed its link), and the Copy sheet's scan below + // would make no progress through such a wall of full-width rows (a TUI's bordered box draws one). + function paragraphContaining(view, row) { + if (!view.line(row)) return null + var start = row + while (start > 0 && row - start < MAX_JOIN_ROWS - 1 && continuesOnNextRow(view, start - 1)) start-- + var text = '' + var rowOffsets = [] + var r = start + for (;;) { + var joins = r - start + 1 < MAX_JOIN_ROWS && continuesOnNextRow(view, r) + var lineText = view.line(r).text(!joins) + rowOffsets.push(text.length) + text += joins ? (view.history ? lineText + ' '.repeat(Math.max(0, view.cols - historyCellWidth(lineText))) : lineText.padEnd(view.cols).slice(0, view.cols)) : lineText + if (!joins) break + r++ + } + return { text: text, startRow: start, rows: r - start + 1, rowOffsets: rowOffsets } + } + + /** An ILink range (1-based, inclusive) for a token at `startIndex..+len` of a paragraph. */ + function tokenRange(startRow, cols, startIndex, len) { + var endIndex = startIndex + len - 1 + return { + start: { x: (startIndex % cols) + 1, y: startRow + Math.floor(startIndex / cols) + 1 }, + end: { x: (endIndex % cols) + 1, y: startRow + Math.floor(endIndex / cols) + 1 } + } + } + + /** + * The http(s) URI of the OSC 8 link at a cell, or null. Read through xterm's private + * `extended.urlId` and `_core._oscLinkService`, as the desktop's osc8UrlAt does: the public buffer + * API has no hyperlink data. A fresh cell per read: a reused one keeps the previous cell's `extended`. + */ + function oscLinkUri(urlId) { + var core = term._core + var service = core && core._oscLinkService + var data = urlId && service ? service.getLinkData(urlId) : null + return data && data.uri && httpHref(data.uri) ? data.uri : null + } + function osc8UrlAt(row, col) { + var line = term.buffer.active.getLine(row) + var cell = line && line.getCell(col) + return oscLinkUri(cell && cell.extended ? cell.extended.urlId : 0) + } + + /** The URL at a buffer cell: an OSC 8 link's, else a URL in the text's paragraph. */ + function linkAt(row, col) { + if (closedPage) { + var capturedRow = closedPage.rows[row] + var link = capturedRow && capturedRow.links.find(function (link) { return col >= link.start && col < link.end }) + if (link) return link.url + } + var capturedPage = closedPage || historyPage + var osc8 = capturedPage ? null : osc8UrlAt(row, col) + if (osc8) return osc8 + var p = paragraphContaining(bufferView(), row) + if (!p) return null + var idx = capturedPage ? p.rowOffsets[row - p.startRow] + historyStringIndex(capturedPage.rows[row].text, col) + : (row - p.startRow) * term.cols + col + var tokens = matchUrlTokens(p.text) + for (var i = 0; i < tokens.length; i++) { + var t = tokens[i] + if (idx >= t.startIndex && idx < t.startIndex + t.text.length) return t.url + } + return null + } + + /** The cell (0-based column, 0-based buffer row) under a point in the page, or null. */ + function cellAt(x, y) { + var screen = term.element && term.element.querySelector('.xterm-screen') + if (!screen || term.cols <= 0 || term.rows <= 0) return null + var rect = closedPage ? closedPage.metrics : screen.getBoundingClientRect() + var dx = x - rect.left + var dy = y - rect.top + if (dx < 0 || dy < 0 || dx >= rect.width || dy >= rect.height) return null + var cw = closedPage ? rect.cellWidth : rect.width / term.cols + var ch = closedPage ? rect.cellHeight : rect.height / term.rows + if (cw <= 0 || ch <= 0) return null + var row = Math.floor(dy / ch) + var capturedPage = closedPage || historyPage + if (capturedPage && (row >= capturedPage.rows.length || Math.floor(dx / cw) >= capturedPage.cols)) return null + return { col: Math.floor(dx / cw), row: row + (capturedPage ? 0 : term.buffer.active.viewportY) } + } + + // A mouse click on a link (a phone with a mouse, while the pane does not report the mouse; xterm's + // own link handling stands aside when it does). On a touch screen the tap handler below runs first. + term.registerLinkProvider({ + provideLinks: function (y, callback) { + if (closedPage || historyPage) { callback(undefined); return } + var p = paragraphContaining(bufferView(), y - 1) + if (!p) { + callback(undefined) + return + } + var links = matchUrlTokens(p.text).map(function (u) { + return { + text: u.text, + range: tokenRange(p.startRow, term.cols, u.startIndex, u.text.length), + activate: function () { openUrl(u.url) } + } + }) + callback(links.length ? links : undefined) + } + }) + term.options.linkHandler = { + activate: function (event, uri) { openUrl(uri) }, + allowNonHttpProtocols: false + } + + // The Copy sheet (audit A32): the buffer's last SNAPSHOT_ROWS rows (under tmux, the alternate screen + // has no scrollback, so that is the visible screen), soft wraps joined into one line each, and the + // http(s) links in them (text URLs joined across wraps as above, and OSC 8 links). The app shows the + // lines to select, copies or shares them under its clipboard cap, and offers the links. + var SNAPSHOT_ROWS = 500 + var SNAPSHOT_LINKS = 50 + function snapshot(liveOnly) { + if (closedPage && !liveOnly) return closedPage.copy + if (historyPage && !liveOnly) { + var captured = historyPage.rows + var lines = [] + captured.forEach(function (row, index) { + if (row.isWrapped && lines.length && captured[index - 1].section === row.section) { + var previous = captured[index - 1].text + lines[lines.length - 1] += ' '.repeat(Math.max(0, historyPage.cols - historyCellWidth(previous))) + row.text + } + else lines.push(row.text) + }) + var links = [] + for (var hr = 0; hr < captured.length;) { + var paragraph = paragraphContaining(bufferView(), hr) + if (!paragraph) break + matchUrlTokens(paragraph.text).forEach(function (token) { + var href = httpHref(token.url) + if (href && links.indexOf(href) < 0 && links.length < SNAPSHOT_LINKS) links.push(href) + }) + hr = Math.max(hr + 1, paragraph.startRow + paragraph.rows) + } + return { lines: lines, links: links, firstVisible: 0 } + } + var buf = term.buffer.active + var view = bufferView(true) + var end = buf.length + var start = Math.max(0, end - SNAPSHOT_ROWS) + // A line cut off at the top would start part-way through. + while (start < end && view.line(start) && view.line(start).isWrapped) start++ + var lines = [] + var firstVisible = 0 + for (var r = start; r < end; r++) { + var l = view.line(r) + if (!l) break + var next = view.line(r + 1) + var continues = !!(next && next.isWrapped) + var text = l.text(!continues) + if (l.isWrapped && lines.length) lines[lines.length - 1] += text + else lines.push(text) + if (r === buf.viewportY) firstVisible = lines.length - 1 + } + while (lines.length && lines[lines.length - 1] === '') lines.pop() + if (firstVisible > lines.length - 1) firstVisible = Math.max(0, lines.length - 1) + + var links = [] + var seen = {} + for (var row = start; row < end;) { + var p = paragraphContaining(view, row) + if (!p) break + var found = matchUrlTokens(p.text).map(function (t) { return { at: t.startIndex, url: t.url } }) + for (var pr = Math.max(p.startRow, start); pr < p.startRow + p.rows && pr < end; pr++) { + var line = buf.getLine(pr) + var last = 0 + for (var x = 0; line && x < term.cols; x++) { + var cell = line.getCell(x) + var id = cell && cell.extended ? cell.extended.urlId : 0 + if (id && id !== last) { + var uri = oscLinkUri(id) + if (uri) found.push({ at: (pr - p.startRow) * term.cols + x, url: uri }) + } + last = id + } + } + found.sort(function (a, b) { return a.at - b.at }) + for (var i = 0; i < found.length; i++) { + var href = httpHref(found[i].url) + if (href && !Object.prototype.hasOwnProperty.call(seen, href)) { + seen[href] = true + links.push(href) + } + } + row = Math.max(row + 1, p.startRow + p.rows) + } + if (links.length > SNAPSHOT_LINKS) links = links.slice(links.length - SNAPSHOT_LINKS) + return { lines: lines, links: links, firstVisible: firstVisible } + } + + // Vertical swipe → tmux history scroll. A tap (one finger, moved at most TAP_SLOP px) on a link hands + // it to the app, which offers to open it (audit A32). tmux runs `mouse on`, so the mouse events a tap + // produces would also reach the pane as a click. On a link the touchend is cancelled, which suppresses them: the click reaches + // neither tmux nor the app in the pane, and the soft keyboard does not come up. A tap anywhere else + // is left alone. + var TAP_SLOP = 10 + var startY = null + var acc = 0 + var tapX = null + var tapY = null + // One rendered text-row of finger movement requests one wheel notch. Stock tmux advances five + // history rows per notch: this responsive gain lets a short phone swipe reach useful history. + // A host with custom wheel bindings can have a different history distance. + var scrollStep = fontSize * 1.4 + var pendingScroll = [] + var scrollFrame = null + var scrollRequestedAt = 0 + var scrollActive = true + var motionSamples = [] + var lastMotionDelta = 0 + var gestureNotches = 0 + var fling = null + var FLING_SAMPLE_MS = 120 + var FLING_RELEASE_MS = 80 + var FLING_MIN_SPEED = 0.45 + var FLING_MAX_SPEED = 3 + var FLING_STOP_SPEED = 0.06 + var FLING_DECAY_MS = 240 + var FLING_MAX_MS = 1000 + var FLING_MAX_PX = 1200 + function cancelScroll() { + if (scrollFrame !== null) window.cancelAnimationFrame(scrollFrame) + scrollFrame = null + pendingScroll = [] + acc = 0 + startY = null + tapX = null + motionSamples = [] + lastMotionDelta = 0 + gestureNotches = 0 + fling = null + // A new touch/input/lifecycle barrier must also stop accepted but unsent native movement. + // The bridge is harmless before a stream is attached; older page-test stubs may omit it. + if (typeof bridge.onScrollStop === 'function') bridge.onScrollStop() + } + function recordMotion(y, time, delta) { + if (!(time > 0) || !isFinite(time)) { motionSamples = []; return } + var last = motionSamples[motionSamples.length - 1] + if (last && time <= last.time) { motionSamples = [{ y: y, time: time }]; return } + // A reversal starts a new velocity sample at the turn, rather than carrying the old fling's + // direction into a swipe whose last movement went the other way. + if (last && delta * lastMotionDelta < 0) motionSamples = [last] + motionSamples.push({ y: y, time: time }) + while (motionSamples.length > 1 && time - motionSamples[0].time > FLING_SAMPLE_MS) motionSamples.shift() + if (delta) lastMotionDelta = delta + } + function startFling(time) { + if (!scrollActive || !gestureNotches || motionSamples.length < 2 || !(time > 0)) return + var first = motionSamples[0] + var last = motionSamples[motionSamples.length - 1] + var elapsed = last.time - first.time + if (elapsed <= 0 || time < last.time || time - last.time > FLING_RELEASE_MS) return + var speed = (last.y - first.y) / elapsed + if (!isFinite(speed) || Math.abs(speed) < FLING_MIN_SPEED) return + speed = Math.max(-FLING_MAX_SPEED, Math.min(FLING_MAX_SPEED, speed)) + var now = performance.now() + fling = { speed: speed, started: now, last: now, distance: 0 } + scheduleScroll() + } + function advanceFling(time) { + if (!fling) return + var dt = Math.min(time - fling.last, FLING_MAX_MS - (fling.last - fling.started)) + // RAF's timestamp can precede the touchend's performance.now within the same frame. Keep + // the fling for the next frame rather than treating that first nonpositive delta as a stop. + if (dt <= 0) return + // Integrate exponential decay over elapsed time so 30/60/120 Hz produce the same distance. + var decay = Math.exp(-dt / FLING_DECAY_MS) + var distance = Math.min(Math.abs(fling.speed) * FLING_DECAY_MS * (1 - decay), FLING_MAX_PX - fling.distance) + acc += (fling.speed > 0 ? 1 : -1) * distance + fling.distance += distance + fling.speed *= decay + fling.last = time + if (Math.abs(fling.speed) < FLING_STOP_SPEED || time - fling.started >= FLING_MAX_MS || fling.distance >= FLING_MAX_PX) fling = null + var notches = Math.floor(Math.abs(acc) / scrollStep) + if (notches) { + var up = acc > 0 + acc -= (up ? 1 : -1) * notches * scrollStep + queueScroll(up, notches) + } + } + function scheduleScroll() { + if (scrollFrame !== null || (!pendingScroll.length && !fling) || !scrollActive) return + scrollRequestedAt = performance.now() + scrollFrame = window.requestAnimationFrame(function (time) { + scrollFrame = null + // A suspended page must not replay an old swipe after returning or after an input/reset. + if (time - scrollRequestedAt > 250) { cancelScroll(); return } + advanceFling(time) + var next = pendingScroll[0] + if (!scrollActive) return + // Both transports clamp to 20. Retain the rest and drain one ordered request per frame, + // including after touchend, rather than silently losing a fast swipe's distance. + if (next) { + var notches = Math.min(20, next.notches) + next.notches -= notches + if (!next.notches) pendingScroll.shift() + if (typeof bridge.onScrollView === 'function') bridge.onScrollView(next.up, notches, displayEpoch) + else bridge.onScroll(next.up, notches) + } + scheduleScroll() + }) + } + function queueScroll(up, notches) { + if (!notches || !scrollActive) return + var last = pendingScroll[pendingScroll.length - 1] + if (last && last.up === up) last.notches += notches + else pendingScroll.push({ up: up, notches: notches }) + scheduleScroll() + } + function measuredScrollStep() { + var screen = term.element && term.element.querySelector('.xterm-screen') + var rowHeight = screen && term.rows > 0 ? screen.getBoundingClientRect().height / term.rows : 0 + return rowHeight > 0 && isFinite(rowHeight) ? rowHeight : fontSize * 1.4 + } + window.addEventListener('pagehide', cancelScroll) + window.addEventListener('blur', cancelScroll) + document.addEventListener('visibilitychange', function () { + if (document.visibilityState === 'hidden') cancelScroll() + }) + host.addEventListener('touchstart', function (e) { + if (e.touches.length === 1) { + cancelScroll() + startY = e.touches[0].clientY + acc = 0 + // Measure once per gesture; querying layout on every touchmove would force repeated work. + scrollStep = measuredScrollStep() + tapX = e.touches[0].clientX + tapY = e.touches[0].clientY + recordMotion(startY, e.timeStamp, 0) + } else { + cancelScroll() + } + }, { passive: true }) + host.addEventListener('touchmove', function (e) { + if (startY === null || e.touches.length !== 1) return + var y = e.touches[0].clientY + if (tapX !== null && (Math.abs(e.touches[0].clientX - tapX) > TAP_SLOP || Math.abs(y - tapY) > TAP_SLOP)) tapX = null + var delta = y - startY + recordMotion(y, e.timeStamp, delta) + acc += delta + startY = y + var notches = Math.floor(Math.abs(acc) / scrollStep) + if (notches) { + gestureNotches += notches + var up = acc > 0 + acc -= (up ? 1 : -1) * notches * scrollStep + queueScroll(up, notches) + } + e.preventDefault() + }, { passive: false }) + host.addEventListener('touchend', function (e) { + startY = null + var x = tapX + var y = tapY + tapX = null + if (e.touches.length > 0) { cancelScroll(); return } + if (x === null) { startFling(e.timeStamp); return } + var cell = cellAt(x, y) + var url = cell ? linkAt(cell.row, cell.col) : null + if (!url) return + e.preventDefault() + openUrl(url) + }, { passive: false }) + host.addEventListener('touchcancel', cancelScroll, { passive: true }) + + window.nt = { + beginViewer: beginViewer, + endViewer: endViewerDisplay, + retireViewer: retireViewer, + write: function (b64) { term.write(b64ToBytes(b64)) }, + // The attach snapshot: the current screen, painted before live output. + paint: function (b64) { clearClosedCapture(); cancelInputScroll(); term.reset(); term.write(b64ToText(b64).replace(/\r?\n/g, '\r\n')) }, + reset: function () { clearClosedCapture(); cancelInputScroll(); term.reset() }, + showScrollView: showScrollView, + closeScrollView: closeScrollView, + scrollFailed: function (epoch) { if (epoch === displayEpoch) cancelScroll() }, + cancelScroll: cancelScroll, + suspendScroll: function () { retireViewer(); scrollActive = false; cancelInputScroll() }, + resumeScroll: function () { if (!closedPage) scrollActive = true }, + // Native raw chips cancel on this JS thread before their input reaches the host. + raw: function (b64) { if (closedPage) return; cancelInputScroll(); bridge.onInput(b64ToText(b64)) }, + focus: function () { term.focus() }, + blur: function () { term.blur() }, + // The ⌨ chip (audit A46): the Kotlin side has just given the WebView Android's focus and asks + // the system for the soft keyboard; this puts the page's focus on xterm's textarea for it. + // Blur first: Blink's focus() on the element that already has focus returns early and does + // nothing, and that is the normal state once the terminal has been tapped. (With focus + // reporting on, xterm reports a focus-out and a focus-in to the pane for this.) + focusForKeyboard: function () { term.blur(); term.focus() }, + // The Copy sheet (audit A32): what the buffer holds, as JSON, for the app to show. + copySheet: function () { bridge.onCopySheet(JSON.stringify(snapshot())) }, + setFontSize: function (n) { cancelInputScroll(); fontSize = n; term.options.fontSize = n; doFit(true) }, + refit: function () { doFit(true) }, + // A composed line from the native input bar. `term.paste` frames it as a bracketed paste when + // the client side asked for one, so a multi-line prompt reaches an agent CLI as ONE paste; the + // Enter is a separate, slightly later write (an Enter inside the paste write is what left + // Codex holding a rendered-but-unsubmitted envelope — see CLAUDE.md, settled submit). + // Special keys from the native key row. Arrows follow the pane's DECCKM state, which this + // emulator tracks because it parses the very stream the pane writes. + key: function (name) { + if (closedPage) return + cancelInputScroll() + var app = term.modes.applicationCursorKeysMode + var csi = app ? '\x1bO' : '\x1b[' + var map = { + up: csi + 'A', down: csi + 'B', right: csi + 'C', left: csi + 'D', + home: '\x1b[H', end: '\x1b[F', pgup: '\x1b[5~', pgdn: '\x1b[6~', + esc: '\x1b', tab: '\t', stab: '\x1b[Z', enter: '\r', nl: '\x1b\r' + } + var seq = map[name] + if (seq) bridge.onInput(seq) + }, + submit: function (b64, enter) { + if (closedPage) return + cancelInputScroll() + var text = b64ToText(b64) + if (text) term.paste(text) + var epoch = viewerEpoch + if (enter) setTimeout(function () { if (closedPage || viewerEpoch !== epoch) return; cancelInputScroll(); bridge.onInput('\r') }, text ? 150 : 0) + } + } + + setTimeout(function () { + doFit(true) + bridge.onReady() + }, 30) +})() diff --git a/android/app/src/main/assets/terminal/xterm.css b/android/app/src/main/assets/terminal/xterm.css new file mode 100644 index 0000000000..e97b643905 --- /dev/null +++ b/android/app/src/main/assets/terminal/xterm.css @@ -0,0 +1,218 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +/** + * Default styles for xterm.js + */ + +.xterm { + cursor: text; + position: relative; + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; +} + +.xterm.focus, +.xterm:focus { + outline: none; +} + +.xterm .xterm-helpers { + position: absolute; + top: 0; + /** + * The z-index of the helpers must be higher than the canvases in order for + * IMEs to appear on top. + */ + z-index: 5; +} + +.xterm .xterm-helper-textarea { + padding: 0; + border: 0; + margin: 0; + /* Move textarea out of the screen to the far left, so that the cursor is not visible */ + position: absolute; + opacity: 0; + left: -9999em; + top: 0; + width: 0; + height: 0; + z-index: -5; + /** Prevent wrapping so the IME appears against the textarea at the correct position */ + white-space: nowrap; + overflow: hidden; + resize: none; +} + +.xterm .composition-view { + /* TODO: Composition position got messed up somewhere */ + background: #000; + color: #FFF; + display: none; + position: absolute; + white-space: nowrap; + z-index: 1; +} + +.xterm .composition-view.active { + display: block; +} + +.xterm .xterm-viewport { + /* On OS X this is required in order for the scroll bar to appear fully opaque */ + background-color: #000; + overflow-y: scroll; + cursor: default; + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; +} + +.xterm .xterm-screen { + position: relative; +} + +.xterm .xterm-screen canvas { + position: absolute; + left: 0; + top: 0; +} + +.xterm .xterm-scroll-area { + visibility: hidden; +} + +.xterm-char-measure-element { + display: inline-block; + visibility: hidden; + position: absolute; + top: 0; + left: -9999em; + line-height: normal; +} + +.xterm.enable-mouse-events { + /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ + cursor: default; +} + +.xterm.xterm-cursor-pointer, +.xterm .xterm-cursor-pointer { + cursor: pointer; +} + +.xterm.column-select.focus { + /* Column selection mode */ + cursor: crosshair; +} + +.xterm .xterm-accessibility:not(.debug), +.xterm .xterm-message { + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 10; + color: transparent; + pointer-events: none; +} + +.xterm .xterm-accessibility-tree:not(.debug) *::selection { + color: transparent; +} + +.xterm .xterm-accessibility-tree { + user-select: text; + white-space: pre; +} + +.xterm .live-region { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.xterm-dim { + /* Dim should not apply to background, so the opacity of the foreground color is applied + * explicitly in the generated class and reset to 1 here */ + opacity: 1 !important; +} + +.xterm-underline-1 { text-decoration: underline; } +.xterm-underline-2 { text-decoration: double underline; } +.xterm-underline-3 { text-decoration: wavy underline; } +.xterm-underline-4 { text-decoration: dotted underline; } +.xterm-underline-5 { text-decoration: dashed underline; } + +.xterm-overline { + text-decoration: overline; +} + +.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } +.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } +.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } +.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } +.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } + +.xterm-strikethrough { + text-decoration: line-through; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration { + z-index: 6; + position: absolute; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { + z-index: 7; +} + +.xterm-decoration-overview-ruler { + z-index: 8; + position: absolute; + top: 0; + right: 0; + pointer-events: none; +} + +.xterm-decoration-top { + z-index: 2; + position: relative; +} diff --git a/android/app/src/main/assets/terminal/xterm.js b/android/app/src/main/assets/terminal/xterm.js new file mode 100644 index 0000000000..7ca75f8e5e --- /dev/null +++ b/android/app/src/main/assets/terminal/xterm.js @@ -0,0 +1,2 @@ +!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=document.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let n=t=this._terminal.cols&&(++s,n=0),{row:s,column:n}},n=r(t),o=r(i);if(n&&o){if(n.row>o.row||n.row===o.row&&n.column>=o.column)throw new Error("invalid range");this._terminal.select(n.column,n.row,(o.row-n.row)*this._terminal.cols-n.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.rightClickHandler=t.moveTextAreaUnderMouseCursor=t.paste=t.handlePasteEvent=t.copyHandler=t.bracketTextForPaste=t.prepareTextForTerminal=void 0,t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7239:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(1505);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},3656:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.addDisposableDomListener=void 0,t.addDisposableDomListener=function(e,t,i,s){e.addEventListener(t,i,s);let r=!1;return{dispose:()=>{r||(r=!0,e.removeEventListener(t,i,s))}}}},3551:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const n=i(3656),o=i(8460),a=i(844),h=i(2585),c=i(4725);let l=t.Linkifier=class extends a.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new o.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new o.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,a.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,a.toDisposable)((()=>{this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0}))),this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,(t=>{if(this._isMouseOut)return;const r=t?.map((e=>({link:e})));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);t&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,a.disposeArray)(this._linkCacheDisposables))}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=l=s([r(1,c.IMouseService),r(2,c.IRenderService),r(3,h.IBufferService),r(4,c.ILinkProviderService)],l)},9042:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0,t.promptLabel="Terminal input",t.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(511),o=i(2585);let a=t.OscLinkProvider=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=new n.CellData,a=i.getTrimmedLength();let c=-1,l=-1,d=!1;for(let t=0;tr?r.activate(e,t,n):h(0,t),hover:(e,t)=>r?.hover?.(e,t,n),leave:(e,t)=>r?.leave?.(e,t,n)})}d=!1,o.hasExtendedAttrs()&&o.extended.urlId?(l=t,c=o.extended.urlId):(l=-1,c=-1)}}t(s)}};function h(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch{}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},6193:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},3236:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Terminal=void 0;const s=i(3614),r=i(3656),n=i(3551),o=i(9042),a=i(3730),h=i(1680),c=i(3107),l=i(5744),d=i(2950),_=i(1296),u=i(428),f=i(4269),v=i(5114),p=i(8934),g=i(3230),m=i(9312),S=i(4725),C=i(6731),b=i(8055),w=i(8969),y=i(8460),E=i(844),k=i(6114),L=i(8437),D=i(2584),R=i(7399),x=i(5941),A=i(9074),B=i(2585),T=i(5435),M=i(4567),O=i(779);class P extends w.CoreTerminal{get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this.browser=k,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this.register(new E.MutableDisposable),this._onCursorMove=this.register(new y.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this.register(new y.EventEmitter),this.onKey=this._onKey.event,this._onRender=this.register(new y.EventEmitter),this.onRender=this._onRender.event,this._onSelectionChange=this.register(new y.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this.register(new y.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this.register(new y.EventEmitter),this.onBell=this._onBell.event,this._onFocus=this.register(new y.EventEmitter),this._onBlur=this.register(new y.EventEmitter),this._onA11yCharEmitter=this.register(new y.EventEmitter),this._onA11yTabEmitter=this.register(new y.EventEmitter),this._onWillOpen=this.register(new y.EventEmitter),this._setup(),this._decorationService=this._instantiationService.createInstance(A.DecorationService),this._instantiationService.setService(B.IDecorationService,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(O.LinkProviderService),this._instantiationService.setService(S.ILinkProviderService,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(a.OscLinkProvider)),this.register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this.register(this._inputHandler.onRequestRefreshRows(((e,t)=>this.refresh(e,t)))),this.register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this.register(this._inputHandler.onRequestReset((()=>this.reset()))),this.register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this.register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this.register((0,y.forwardEvent)(this._inputHandler.onCursorMove,this._onCursorMove)),this.register((0,y.forwardEvent)(this._inputHandler.onTitleChange,this._onTitleChange)),this.register((0,y.forwardEvent)(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this.register((0,y.forwardEvent)(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this.register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this.register((0,E.toDisposable)((()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${D.C0.ESC}]${i};${(0,x.toRgbString)(s)}${D.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.channels.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.channels.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[I"),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this.register((0,r.addDisposableDomListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this.register((0,r.addDisposableDomListener)(this.textarea,"paste",e)),this.register((0,r.addDisposableDomListener)(this.element,"paste",e)),k.isFirefox?this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this.register((0,r.addDisposableDomListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),k.isLinux&&this.register((0,r.addDisposableDomListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this.register((0,r.addDisposableDomListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this.register((0,r.addDisposableDomListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this.register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this._viewportScrollArea=this._document.createElement("div"),this._viewportScrollArea.classList.add("xterm-scroll-area"),this._viewportElement.appendChild(this._viewportScrollArea),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this.register((0,r.addDisposableDomListener)(this.screenElement,"mousemove",(e=>this.updateCursorStyle(e)))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement),this.textarea=this._document.createElement("textarea"),this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",o.promptLabel),k.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._coreBrowserService=this.register(this._instantiationService.createInstance(v.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??"undefined"!=typeof window?window.document:null)),this._instantiationService.setService(S.ICoreBrowserService,this._coreBrowserService),this.register((0,r.addDisposableDomListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(u.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(S.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(C.ThemeService),this._instantiationService.setService(S.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(f.CharacterJoinerService),this._instantiationService.setService(S.ICharacterJoinerService,this._characterJoinerService),this._renderService=this.register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(S.IRenderService,this._renderService),this.register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(d.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(S.IMouseService,this._mouseService),this.linkifier=this.register(this._instantiationService.createInstance(n.Linkifier,this.screenElement)),this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this.viewport=this._instantiationService.createInstance(h.Viewport,this._viewportElement,this._viewportScrollArea),this.viewport.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent,1))),this.register(this._inputHandler.onRequestSyncScrollBar((()=>this.viewport.syncScrollArea()))),this.register(this.viewport),this.register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this.register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this.register(this.onBlur((()=>this._renderService.handleBlur()))),this.register(this.onFocus((()=>this._renderService.handleFocus()))),this.register(this._renderService.onDimensionsChange((()=>this.viewport.syncScrollArea()))),this._selectionService=this.register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,this.linkifier)),this._instantiationService.setService(S.ISelectionService,this._selectionService),this.register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this.register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this.register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this.register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this.register(this._onScroll.event((e=>{this.viewport.syncScrollArea(),this._selectionService.refresh()}))),this.register((0,r.addDisposableDomListener)(this._viewportElement,"scroll",(()=>this._selectionService.refresh()))),this.register(this._instantiationService.createInstance(c.BufferDecorationRenderer,this.screenElement)),this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)),this.register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRulerWidth&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRulerWidth",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(_.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(e._customWheelEventHandler&&!1===e._customWheelEventHandler(t))return!1;if(0===e.viewport.getLinesScrolled(t))return!1;r=t.deltaY<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},n={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this.register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",n.mousemove),s.mousemove=n.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",n.wheel,{passive:!1}),s.wheel=n.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(s.mouseup=n.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=n.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,r.addDisposableDomListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this.register((0,r.addDisposableDomListener)(t,"wheel",(e=>{if(!s.wheel){if(this._customWheelEventHandler&&!1===this._customWheelEventHandler(e))return!1;if(!this.buffer.hasScrollback){const t=this.viewport.getLinesScrolled(e);if(0===t)return;const i=D.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");let s="";for(let e=0;e{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchStart(e),this.cancel(e)}),{passive:!0})),this.register((0,r.addDisposableDomListener)(t,"touchmove",(e=>{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchMove(e)?void 0:this.cancel(e)}),{passive:!1}))}refresh(e,t){this._renderService?.refreshRows(e,t)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t,i=0){1===i?(super.scrollLines(e,t,i),this.refresh(0,this.rows-1)):this.viewport?.scrollLines(e)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this._customWheelEventHandler=e}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,R.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==D.C0.ETX&&i.key!==D.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure(),this.viewport?.syncScrollArea(!0)}clear(){if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=Date.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=Date.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},1680:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(3656),o=i(4725),a=i(8460),h=i(844),c=i(2585);let l=t.Viewport=class extends h.Disposable{constructor(e,t,i,s,r,o,h,c){super(),this._viewportElement=e,this._scrollArea=t,this._bufferService=i,this._optionsService=s,this._charSizeService=r,this._renderService=o,this._coreBrowserService=h,this.scrollBarWidth=0,this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._wheelPartialScroll=0,this._refreshAnimationFrame=null,this._ignoreNextScrollEvent=!1,this._smoothScrollState={startTime:0,origin:-1,target:-1},this._onRequestScrollLines=this.register(new a.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this.scrollBarWidth=this._viewportElement.offsetWidth-this._scrollArea.offsetWidth||15,this.register((0,n.addDisposableDomListener)(this._viewportElement,"scroll",this._handleScroll.bind(this))),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._renderDimensions=this._renderService.dimensions,this.register(this._renderService.onDimensionsChange((e=>this._renderDimensions=e))),this._handleThemeChange(c.colors),this.register(c.onChangeColors((e=>this._handleThemeChange(e)))),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.syncScrollArea()))),setTimeout((()=>this.syncScrollArea()))}_handleThemeChange(e){this._viewportElement.style.backgroundColor=e.background.css}reset(){this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._coreBrowserService.window.requestAnimationFrame((()=>this.syncScrollArea()))}_refresh(e){if(e)return this._innerRefresh(),void(null!==this._refreshAnimationFrame&&this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame));null===this._refreshAnimationFrame&&(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderDimensions.device.cell.height/this._coreBrowserService.dpr,this._currentDeviceCellHeight=this._renderDimensions.device.cell.height,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;const e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderDimensions.css.canvas.height);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}const e=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==e&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=e),this._refreshAnimationFrame=null}syncScrollArea(e=!1){if(this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(e);this._lastRecordedViewportHeight===this._renderService.dimensions.css.canvas.height&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.device.cell.height===this._currentDeviceCellHeight||this._refresh(e)}_handleScroll(e){if(this._lastScrollTop=this._viewportElement.scrollTop,!this._viewportElement.offsetParent)return;if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._onRequestScrollLines.fire({amount:0,suppressScrollEvent:!0});const t=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._onRequestScrollLines.fire({amount:t,suppressScrollEvent:!0})}_smoothScroll(){if(this._isDisposed||-1===this._smoothScrollState.origin||-1===this._smoothScrollState.target)return;const e=this._smoothScrollPercent();this._viewportElement.scrollTop=this._smoothScrollState.origin+Math.round(e*(this._smoothScrollState.target-this._smoothScrollState.origin)),e<1?this._coreBrowserService.window.requestAnimationFrame((()=>this._smoothScroll())):this._clearSmoothScrollState()}_smoothScrollPercent(){return this._optionsService.rawOptions.smoothScrollDuration&&this._smoothScrollState.startTime?Math.max(Math.min((Date.now()-this._smoothScrollState.startTime)/this._optionsService.rawOptions.smoothScrollDuration,1),0):1}_clearSmoothScrollState(){this._smoothScrollState.startTime=0,this._smoothScrollState.origin=-1,this._smoothScrollState.target=-1}_bubbleScroll(e,t){const i=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(t<0&&0!==this._viewportElement.scrollTop||t>0&&i0&&(i=e),s=""}}return{bufferElements:r,cursorElement:i}}getLinesScrolled(e){if(0===e.deltaY||e.shiftKey)return 0;let t=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(t/=this._currentRowHeight+0,this._wheelPartialScroll+=t,t=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(t*=this._bufferService.rows),t}_applyScrollModifier(e,t){const i=this._optionsService.rawOptions.fastScrollModifier;return"alt"===i&&t.altKey||"ctrl"===i&&t.ctrlKey||"shift"===i&&t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}handleTouchStart(e){this._lastTouchY=e.touches[0].pageY}handleTouchMove(e){const t=this._lastTouchY-e.touches[0].pageY;return this._lastTouchY=e.touches[0].pageY,0!==t&&(this._viewportElement.scrollTop+=t,this._bubbleScroll(e,t))}};t.Viewport=l=s([r(2,c.IBufferService),r(3,c.IOptionsService),r(4,o.ICharSizeService),r(5,o.IRenderService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],l)},3107:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(4725),o=i(844),a=i(2585);let h=t.BufferDecorationRenderer=class extends o.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this.register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this.register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this.register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this.register((0,o.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.display=this._altBufferIsActive?"none":"block",e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=h=s([r(1,a.IBufferService),r(2,n.ICoreBrowserService),r(3,a.IDecorationService),r(4,n.IRenderService)],h)},5871:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},5744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(5871),o=i(4725),a=i(844),h=i(2585),c={full:0,left:0,center:0,right:0},l={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let _=t.OverviewRulerRenderer=class extends a.Disposable{get _width(){return this._optionsService.options.overviewRulerWidth||0}constructor(e,t,i,s,r,o,h){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._coreBrowserService=h,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement);const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._registerDecorationListeners(),this._registerBufferChangeListeners(),this._registerDimensionChangeListeners(),this.register((0,a.toDisposable)((()=>{this._canvas?.remove()})))}_registerDecorationListeners(){this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this.register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0))))}_registerBufferChangeListeners(){this.register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})))}_registerDimensionChangeListeners(){this.register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this.register(this._optionsService.onSpecificOptionChange("overviewRulerWidth",(()=>this._queueRefresh(!0)))),this.register(this._coreBrowserService.onDprChange((()=>this._queueRefresh(!0)))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor(this._canvas.width/3),t=Math.ceil(this._canvas.width/3);l.full=this._canvas.width,l.left=e,l.center=t,l.right=e,this._refreshDrawHeightConstants(),d.full=0,d.left=0,d.center=l.left,d.right=l.left+l.center}_refreshDrawHeightConstants(){c.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);c.left=t,c.center=t,c.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*c.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1;const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-c[e.position||"full"]/2),l[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+c[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=_=s([r(2,h.IBufferService),r(3,h.IDecorationService),r(4,o.IRenderService),r(5,h.IOptionsService),r(6,o.ICoreBrowserService)],_)},2950:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(4725),o=i(2585),a=i(2584);let h=t.CompositionHelper=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,e.end):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=h=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],h)},9806:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoords=t.getCoordsRelativeToElement=void 0,t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,h,c){if(!o)return;const l=i(e,t,s);return l?(l[0]=Math.ceil((l[0]+(c?a/2:0))/a),l[1]=Math.ceil(l[1]/h),l[0]=Math.min(Math.max(l[0],1),r+(c?1:0)),l[1]=Math.min(Math.max(l[1],1),n),l):void 0}},9504:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=void 0;const s=i(2584);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),l=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,h="";for(;o!==i||a!==s;)o+=r?1:-1,r&&o>n.cols-1?(h+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(h+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return h+n.buffer.translateBufferLineToString(a,!1,e,o)}function h(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function c(e,t){e=Math.floor(e);let i="";for(let s=0;s0?s-n(s,o):t;const _=s,u=function(e,t,i,s,o,a){let h;return h=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&he?"D":"C",c(Math.abs(o-e),h(d,s));d=l>t?"D":"C";const _=Math.abs(l-t);return c(function(e,t){return t.cols-e}(l>t?e:o,i)+(_-1)*i.cols+1+((l>t?o:e)-1),h(d,s))}},1296:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(3787),o=i(2550),a=i(2223),h=i(6171),c=i(6052),l=i(4725),d=i(8055),_=i(8460),u=i(844),f=i(2585),v="xterm-dom-renderer-owner-",p="xterm-rows",g="xterm-fg-",m="xterm-bg-",S="xterm-focus",C="xterm-selection";let b=1,w=t.DomRenderer=class extends u.Disposable{constructor(e,t,i,s,r,a,l,d,f,g,m,S,w){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=l,this._charSizeService=f,this._optionsService=g,this._bufferService=m,this._coreBrowserService=S,this._themeService=w,this._terminalClass=b++,this._rowElements=[],this._selectionRenderModel=(0,c.createSelectionRenderModel)(),this.onRequestRedraw=this.register(new _.EventEmitter).event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add(p),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add(C),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this.register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=d.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(v+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this.register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this.register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this.register((0,u.toDisposable)((()=>{this._element.classList.remove(v+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${p} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${p} { color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${p} .xterm-dim { color: ${d.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .${p}.${S} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .${p} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${C} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${C} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${C} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${g}${i} { color: ${s.css}; }${this._terminalSelector} .${g}${i}.xterm-dim { color: ${d.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${m}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR} { color: ${d.color.opaque(e.background).css}; }${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${d.color.multiplyOpacity(d.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${m}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(S),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(S),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;this._selectionRenderModel.update(this._terminal,e,t,i);const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,n=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow;if(n>=this._bufferService.rows||o<0)return;const a=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,h=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,h));const c=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,c)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=this._document.createElement("div"),n=t*this.dimensions.css.cell.width;let o=this.dimensions.css.cell.width*(i-t);return n+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-n),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${n}px`,r.style.width=`${o}px`,r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._optionsService.rawOptions.cursorBlink,o=this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let h=e;h<=t;h++){const e=h+i.ydisp,t=this._rowElements[h],c=i.lines.get(e);if(!t||!c)break;t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${v}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,c=Math.min(a.x,r-1),l=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const u=o+a.ydisp,f=this._rowElements[o],v=a.lines.get(u);if(!f||!v)break;f.replaceChildren(...this._rowFactory.createRow(v,u,u===h,d,_,c,l,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=w=s([r(7,f.IInstantiationService),r(8,l.ICharSizeService),r(9,f.IOptionsService),r(10,f.IBufferService),r(11,l.ICoreBrowserService),r(12,l.IThemeService)],w)},3787:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(2223),o=i(643),a=i(511),h=i(2585),c=i(8055),l=i(4725),d=i(4269),_=i(6171),u=i(3734);let f=t.DomRendererRowFactory=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,l,_,f,p){const g=[],m=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let C,b=e.getNoBgTrimmedLength();i&&b0&&M===m[0][0]){O=!0;const t=m.shift();I=new d.JoinedCellData(this._workCell,e.translateToString(!0,t[0],t[1]),t[1]-t[0]),P=t[1]-1,b=I.getWidth()}const H=this._isCellInSelection(M,t),F=i&&M===a,W=T&&M>=f&&M<=p;let U=!1;this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{U=!0}));let N=I.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===N&&(I.isUnderline()||I.isOverline())&&(N=" "),A=b*l-_.get(N,I.isBold(),I.isItalic()),C){if(w&&(H&&x||!H&&!x&&I.bg===E)&&(H&&x&&S.selectionForeground||I.fg===k)&&I.extended.ext===L&&W===D&&A===R&&!F&&!O&&!U){I.isInvisible()?y+=o.WHITESPACE_CELL_CHAR:y+=N,w++;continue}w&&(C.textContent=y),C=this._document.createElement("span"),w=0,y=""}else C=this._document.createElement("span");if(E=I.bg,k=I.fg,L=I.extended.ext,D=W,R=A,x=H,O&&a>=M&&a<=P&&(a=M),!this._coreService.isCursorHidden&&F&&this._coreService.isCursorInitialized)if(B.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&B.push("xterm-cursor-blink"),B.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":B.push("xterm-cursor-outline");break;case"block":B.push("xterm-cursor-block");break;case"bar":B.push("xterm-cursor-bar");break;case"underline":B.push("xterm-cursor-underline")}if(I.isBold()&&B.push("xterm-bold"),I.isItalic()&&B.push("xterm-italic"),I.isDim()&&B.push("xterm-dim"),y=I.isInvisible()?o.WHITESPACE_CELL_CHAR:I.getChars()||o.WHITESPACE_CELL_CHAR,I.isUnderline()&&(B.push(`xterm-underline-${I.extended.underlineStyle}`)," "===y&&(y=" "),!I.isUnderlineColorDefault()))if(I.isUnderlineColorRGB())C.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(I.getUnderlineColor()).join(",")})`;else{let e=I.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&I.isBold()&&e<8&&(e+=8),C.style.textDecorationColor=S.ansi[e].css}I.isOverline()&&(B.push("xterm-overline")," "===y&&(y=" ")),I.isStrikethrough()&&B.push("xterm-strikethrough"),W&&(C.style.textDecoration="underline");let $=I.getFgColor(),j=I.getFgColorMode(),z=I.getBgColor(),K=I.getBgColorMode();const q=!!I.isInverse();if(q){const e=$;$=z,z=e;const t=j;j=K,K=t}let V,G,X,J=!1;switch(this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{"top"!==e.options.layer&&J||(e.backgroundColorRGB&&(K=50331648,z=e.backgroundColorRGB.rgba>>8&16777215,V=e.backgroundColorRGB),e.foregroundColorRGB&&(j=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,G=e.foregroundColorRGB),J="top"===e.options.layer)})),!J&&H&&(V=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,z=V.rgba>>8&16777215,K=50331648,J=!0,S.selectionForeground&&(j=50331648,$=S.selectionForeground.rgba>>8&16777215,G=S.selectionForeground)),J&&B.push("xterm-decoration-top"),K){case 16777216:case 33554432:X=S.ansi[z],B.push(`xterm-bg-${z}`);break;case 50331648:X=c.channels.toColor(z>>16,z>>8&255,255&z),this._addStyle(C,`background-color:#${v((z>>>0).toString(16),"0",6)}`);break;default:q?(X=S.foreground,B.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):X=S.background}switch(V||I.isDim()&&(V=c.color.multiplyOpacity(X,.5)),j){case 16777216:case 33554432:I.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(C,X,S.ansi[$],I,V,void 0)||B.push(`xterm-fg-${$}`);break;case 50331648:const e=c.channels.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(C,X,e,I,V,G)||this._addStyle(C,`color:#${v($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(C,X,S.foreground,I,V,G)||q&&B.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}B.length&&(C.className=B.join(" "),B.length=0),F||O||U?C.textContent=y:w++,A!==this.defaultSpacing&&(C.style.letterSpacing=`${A}px`),g.push(C),M=P}return C&&w&&(C.textContent=y),g}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=c.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function v(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e,t){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.classList.add("xterm-width-cache-measure-container"),this._container.setAttribute("aria-hidden","true"),this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const i=e.createElement("span");i.classList.add("xterm-char-measure-element");const s=e.createElement("span");s.classList.add("xterm-char-measure-element"),s.style.fontWeight="bold";const r=e.createElement("span");r.classList.add("xterm-char-measure-element"),r.style.fontStyle="italic";const n=e.createElement("span");n.classList.add("xterm-char-measure-element"),n.style.fontWeight="bold",n.style.fontStyle="italic",this._measureElements=[i,s,r,n],this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),this._container.appendChild(n),t.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),n>0&&this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},2223:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TEXT_BASELINE=t.DIM_OPACITY=t.INVERTED_DEFAULT_COLOR=void 0;const s=i(6114);t.INVERTED_DEFAULT_COLOR=257,t.DIM_OPACITY=.5,t.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},6171:(e,t)=>{function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.computeNextVariantOffset=t.createRenderDimensions=t.treatGlyphAsBackgroundColor=t.allowRescaling=t.isEmoji=t.isRestrictedPowerlineGlyph=t.isPowerlineGlyph=t.throwIfFalsy=void 0,t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,n){return 1===t&&r>Math.ceil(1.5*n)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},6052:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=void 0;class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,n=t[1]-r,o=i[1]-r,a=Math.max(n,0),h=Math.min(o,e.rows-1);a>=e.rows||h<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=n,this.viewportEndRow=o,this.viewportCappedStartRow=a,this.viewportCappedEndRow=h,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}t.createSelectionRenderModel=function(){return new i}},456:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},428:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(2585),o=i(8460),a=i(844);let h=t.CharSizeService=class extends a.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this.register(new o.EventEmitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this.register(new d(this._optionsService))}catch{this._measureStrategy=this.register(new l(e,t,this._optionsService))}this.register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h=s([r(2,n.IOptionsService)],h);class c extends a.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class l extends c{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends c{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},4269:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(3734),o=i(643),a=i(511),h=i(2585);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let l=t.CharacterJoinerService=class e{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new a.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0;const s=i(844),r=i(8460),n=i(3656);class o extends s.Disposable{constructor(e,t,i){super(),this._textarea=e,this._window=t,this.mainDocument=i,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=new a(this._window),this._onDprChange=this.register(new r.EventEmitter),this.onDprChange=this._onDprChange.event,this._onWindowChange=this.register(new r.EventEmitter),this.onWindowChange=this._onWindowChange.event,this.register(this.onWindowChange((e=>this._screenDprMonitor.setWindow(e)))),this.register((0,r.forwardEvent)(this._screenDprMonitor.onDprChange,this._onDprChange)),this._textarea.addEventListener("focus",(()=>this._isFocused=!0)),this._textarea.addEventListener("blur",(()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}t.CoreBrowserService=o;class a extends s.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this.register(new s.MutableDisposable),this._onDprChange=this.register(new r.EventEmitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this.register((0,s.toDisposable)((()=>this.clearListener())))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,n.addDisposableDomListener)(this._parentWindow,"resize",(()=>this._setDprAndFireIfDiffers()))}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},779:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(844);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this.register((0,s.toDisposable)((()=>this.linkProviders.length=0)))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},8934:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(4725),o=i(9806);let a=t.MouseService=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},3230:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(6193),o=i(4725),a=i(8460),h=i(844),c=i(7226),l=i(2585);let d=t.RenderService=class extends h.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,o,l,d){super(),this._rowCount=e,this._charSizeService=s,this._renderer=this.register(new h.MutableDisposable),this._pausedResizeTask=new c.DebouncedIdleTask,this._observerDisposable=this.register(new h.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this.register(new a.EventEmitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this.register(new a.EventEmitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this.register(new a.EventEmitter),this.onRender=this._onRender.event,this._onRefreshRequest=this.register(new a.EventEmitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new n.RenderDebouncer(((e,t)=>this._renderRows(e,t)),l),this.register(this._renderDebouncer),this.register(l.onDprChange((()=>this.handleDevicePixelRatioChange()))),this.register(o.onResize((()=>this._fullRefresh()))),this.register(o.buffers.onBufferActivate((()=>this._renderer.value?.clear()))),this.register(i.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this.register(r.onDecorationRegistered((()=>this._fullRefresh()))),this.register(r.onDecorationRemoved((()=>this._fullRefresh()))),this.register(i.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],(()=>{this.clear(),this.handleResize(o.cols,o.rows),this._fullRefresh()}))),this.register(i.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(o.buffer.y,o.buffer.y,!0)))),this.register(d.onChangeColors((()=>this._fullRefresh()))),this._registerIntersectionObserver(l.window,t),this.register(l.onWindowChange((e=>this._registerIntersectionObserver(e,t))))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});i.observe(t),this._observerDisposable.value=(0,h.toDisposable)((()=>i.disconnect()))}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){this._isPaused?this._needsFullRefresh=!0:(i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount))}_renderRows(e,t){this._renderer.value&&(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0)}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value?.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d=s([r(2,l.IOptionsService),r(3,o.ICharSizeService),r(4,l.IDecorationService),r(5,l.IBufferService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],d)},9312:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(9806),o=i(9504),a=i(456),h=i(4725),c=i(8460),l=i(844),d=i(6114),_=i(4841),u=i(511),f=i(2585),v=String.fromCharCode(160),p=new RegExp(v,"g");let g=t.SelectionService=class extends l.Disposable{constructor(e,t,i,s,r,n,o,h,d){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=h,this._coreBrowserService=d,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this.register(new c.EventEmitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this.register(new c.EventEmitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this.register(new c.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this.register(new c.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this.register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this.register((0,l.toDisposable)((()=>{this._removeMouseDownListeners()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(p," "))).join(d.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),d.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return d.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(d.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),h=a;const c=e[0]-a;let l=0,d=0,_=0,u=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(l++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+c-l+_,v=Math.min(this._bufferService.cols,h-a+l+d-_-u);if(t||""!==o.slice(a,h).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,v+=e}}}if(s&&f+v===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(v+=t.length)}}return{start:f,length:v}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=g=s([r(3,f.IBufferService),r(4,f.ICoreService),r(5,h.IMouseService),r(6,f.IOptionsService),r(7,h.IRenderService),r(8,h.ICoreBrowserService)],g)},4725:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(8343);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService")},6731:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=t.DEFAULT_ANSI_COLORS=void 0;const n=i(7239),o=i(8055),a=i(8460),h=i(844),c=i(2585),l=o.css.toColor("#ffffff"),d=o.css.toColor("#000000"),_=o.css.toColor("#ffffff"),u=o.css.toColor("#000000"),f={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[o.css.toColor("#2e3436"),o.css.toColor("#cc0000"),o.css.toColor("#4e9a06"),o.css.toColor("#c4a000"),o.css.toColor("#3465a4"),o.css.toColor("#75507b"),o.css.toColor("#06989a"),o.css.toColor("#d3d7cf"),o.css.toColor("#555753"),o.css.toColor("#ef2929"),o.css.toColor("#8ae234"),o.css.toColor("#fce94f"),o.css.toColor("#729fcf"),o.css.toColor("#ad7fa8"),o.css.toColor("#34e2e2"),o.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const s=t[i/36%6|0],r=t[i/6%6|0],n=t[i%6];e.push({css:o.channels.toCss(s,r,n),rgba:o.channels.toRgba(s,r,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:o.channels.toCss(i,i,i),rgba:o.channels.toRgba(i,i,i)})}return e})());let v=t.ThemeService=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this.register(new a.EventEmitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:l,background:d,cursor:_,cursorAccent:u,selectionForeground:void 0,selectionBackgroundTransparent:f,selectionBackgroundOpaque:o.color.blend(d,f),selectionInactiveBackgroundTransparent:f,selectionInactiveBackgroundOpaque:o.color.blend(d,f),ansi:t.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this.register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this.register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const i=this._colors;if(i.foreground=p(e.foreground,l),i.background=p(e.background,d),i.cursor=p(e.cursor,_),i.cursorAccent=p(e.cursorAccent,u),i.selectionBackgroundTransparent=p(e.selectionBackground,f),i.selectionBackgroundOpaque=o.color.blend(i.background,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundTransparent=p(e.selectionInactiveBackground,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundOpaque=o.color.blend(i.background,i.selectionInactiveBackgroundTransparent),i.selectionForeground=e.selectionForeground?p(e.selectionForeground,o.NULL_COLOR):void 0,i.selectionForeground===o.NULL_COLOR&&(i.selectionForeground=void 0),o.color.isOpaque(i.selectionBackgroundTransparent)){const e=.3;i.selectionBackgroundTransparent=o.color.opacity(i.selectionBackgroundTransparent,e)}if(o.color.isOpaque(i.selectionInactiveBackgroundTransparent)){const e=.3;i.selectionInactiveBackgroundTransparent=o.color.opacity(i.selectionInactiveBackgroundTransparent,e)}if(i.ansi=t.DEFAULT_ANSI_COLORS.slice(),i.ansi[0]=p(e.black,t.DEFAULT_ANSI_COLORS[0]),i.ansi[1]=p(e.red,t.DEFAULT_ANSI_COLORS[1]),i.ansi[2]=p(e.green,t.DEFAULT_ANSI_COLORS[2]),i.ansi[3]=p(e.yellow,t.DEFAULT_ANSI_COLORS[3]),i.ansi[4]=p(e.blue,t.DEFAULT_ANSI_COLORS[4]),i.ansi[5]=p(e.magenta,t.DEFAULT_ANSI_COLORS[5]),i.ansi[6]=p(e.cyan,t.DEFAULT_ANSI_COLORS[6]),i.ansi[7]=p(e.white,t.DEFAULT_ANSI_COLORS[7]),i.ansi[8]=p(e.brightBlack,t.DEFAULT_ANSI_COLORS[8]),i.ansi[9]=p(e.brightRed,t.DEFAULT_ANSI_COLORS[9]),i.ansi[10]=p(e.brightGreen,t.DEFAULT_ANSI_COLORS[10]),i.ansi[11]=p(e.brightYellow,t.DEFAULT_ANSI_COLORS[11]),i.ansi[12]=p(e.brightBlue,t.DEFAULT_ANSI_COLORS[12]),i.ansi[13]=p(e.brightMagenta,t.DEFAULT_ANSI_COLORS[13]),i.ansi[14]=p(e.brightCyan,t.DEFAULT_ANSI_COLORS[14]),i.ansi[15]=p(e.brightWhite,t.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const s=Math.min(i.ansi.length-16,e.extendedAnsi.length);for(let r=0;r{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this.register(new s.EventEmitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this.register(new s.EventEmitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this.register(new s.EventEmitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=void 0,t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},8055:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.contrastRatio=t.toPaddedHex=t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0;let i=0,s=0,r=0,n=0;var o,a,h,c,l;function d(e){const t=e.toString(16);return t.length<2?"0"+t:t}function _(e,t){return e>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(o||(t.channels=o={})),function(e){function t(e,t){return n=Math.round(255*t),[i,s,r]=l.toChannels(e.rgba),{css:o.toCss(i,s,r,n),rgba:o.toRgba(i,s,r,n)}}e.blend=function(e,t){if(n=(255&t.rgba)/255,1===n)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,h=t.rgba>>16&255,c=t.rgba>>8&255,l=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return i=l+Math.round((a-l)*n),s=d+Math.round((h-d)*n),r=_+Math.round((c-_)*n),{css:o.toCss(i,s,r),rgba:o.toRgba(i,s,r)}},e.isOpaque=function(e){return 255==(255&e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=l.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=l.toChannels(t),{css:o.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return n=255&e.rgba,t(e,n*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n=parseInt(e.slice(4,5).repeat(2),16),o.toColor(i,s,r,n);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const h=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(h)return i=parseInt(h[1]),s=parseInt(h[2]),r=parseInt(h[3]),n=Math.round(255*(void 0===h[5]?1:parseFloat(h[5]))),o.toColor(i,s,r,n);if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,n]=t.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(i,s,r,n),css:e}}}(h||(t.css=h={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(c||(t.rgb=c={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));for(;l0||a>0||h>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));return(o<<24|a<<16|h<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,l=_(c.relativeLuminance2(o,a,h),c.relativeLuminance2(s,r,n));for(;l>>0}e.blend=function(e,t){if(n=(255&t)/255,1===n)return t;const a=t>>24&255,h=t>>16&255,c=t>>8&255,l=e>>24&255,d=e>>16&255,_=e>>8&255;return i=l+Math.round((a-l)*n),s=d+Math.round((h-d)*n),r=_+Math.round((c-_)*n),o.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=c.relativeLuminance(e>>8),n=c.relativeLuminance(i>>8);if(_(r,n)>8));if(o_(r,c.relativeLuminance(t>>8))?n:t}return n}const o=a(e,i,s),h=_(r,c.relativeLuminance(o>>8));if(h_(r,c.relativeLuminance(n>>8))?o:n}return o}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(l||(t.rgba=l={})),t.toPaddedHex=d,t.contrastRatio=_},8969:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(844),r=i(2585),n=i(4348),o=i(7866),a=i(744),h=i(7302),c=i(6975),l=i(8460),d=i(1753),_=i(1480),u=i(7994),f=i(9282),v=i(5435),p=i(5981),g=i(2660);let m=!1;class S extends s.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this.register(new l.EventEmitter),this._onScroll.event((e=>{this._onScrollApi?.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this.register(new s.MutableDisposable),this._onBinary=this.register(new l.EventEmitter),this.onBinary=this._onBinary.event,this._onData=this.register(new l.EventEmitter),this.onData=this._onData.event,this._onLineFeed=this.register(new l.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this.register(new l.EventEmitter),this.onResize=this._onResize.event,this._onWriteParsed=this.register(new l.EventEmitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this.register(new l.EventEmitter),this._instantiationService=new n.InstantiationService,this.optionsService=this.register(new h.OptionsService(e)),this._instantiationService.setService(r.IOptionsService,this.optionsService),this._bufferService=this.register(this._instantiationService.createInstance(a.BufferService)),this._instantiationService.setService(r.IBufferService,this._bufferService),this._logService=this.register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(r.ILogService,this._logService),this.coreService=this.register(this._instantiationService.createInstance(c.CoreService)),this._instantiationService.setService(r.ICoreService,this.coreService),this.coreMouseService=this.register(this._instantiationService.createInstance(d.CoreMouseService)),this._instantiationService.setService(r.ICoreMouseService,this.coreMouseService),this.unicodeService=this.register(this._instantiationService.createInstance(_.UnicodeService)),this._instantiationService.setService(r.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(u.CharsetService),this._instantiationService.setService(r.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(g.OscLinkService),this._instantiationService.setService(r.IOscLinkService,this._oscLinkService),this._inputHandler=this.register(new v.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this.register((0,l.forwardEvent)(this._inputHandler.onLineFeed,this._onLineFeed)),this.register(this._inputHandler),this.register((0,l.forwardEvent)(this._bufferService.onResize,this._onResize)),this.register((0,l.forwardEvent)(this.coreService.onData,this._onData)),this.register((0,l.forwardEvent)(this.coreService.onBinary,this._onBinary)),this.register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom()))),this.register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this.register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this.register(this._bufferService.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this.register(this._inputHandler.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this.register(new p.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this.register((0,l.forwardEvent)(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=r.LogLevelEnum.WARN&&!m&&(this._logService.warn("writeSync is unreliable and will be removed soon."),m=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,a.MINIMUM_COLS),t=Math.max(t,a.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t,i){this._bufferService.scrollLines(e,t,i)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(f.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,f.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,s.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},8460:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.runAndSubscribe=t.forwardEvent=t.EventEmitter=void 0,t.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=e=>(this._listeners.push(e),{dispose:()=>{if(!this._disposed)for(let t=0;tt.fire(e)))},t.runAndSubscribe=function(e,t){return t(void 0),e((e=>t(e)))}},5435:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0;const n=i(2584),o=i(7116),a=i(2015),h=i(844),c=i(482),l=i(8437),d=i(8460),_=i(643),u=i(511),f=i(3734),v=i(2585),p=i(1480),g=i(6242),m=i(6351),S=i(5941),C={"(":0,")":1,"*":2,"+":3,"-":1,".":2},b=131072;function w(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var y;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(y||(t.WindowsOptionsReportType=y={}));let E=0;class k extends h.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,h,_,f,v=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=h,this._coreMouseService=_,this._unicodeService=f,this._parser=v,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new c.StringToUtf32,this._utf8Decoder=new c.Utf8ToUtf32,this._workCell=new u.CellData,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this.register(new d.EventEmitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this.register(new d.EventEmitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this.register(new d.EventEmitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this.register(new d.EventEmitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this.register(new d.EventEmitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this.register(new d.EventEmitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this.register(new d.EventEmitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this.register(new d.EventEmitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this.register(new d.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this.register(new d.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this.register(new d.EventEmitter),this.onScroll=this._onScroll.event,this._onTitleChange=this.register(new d.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this.register(new d.EventEmitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this.register(this._parser),this._dirtyRowTracker=new L(this._bufferService),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new g.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new g.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new g.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new g.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new g.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new g.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new g.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new g.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new g.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new g.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new g.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new g.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new m.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=v.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>b&&(n=this._parseStack.position+b)}if(this._logService.logLevel<=v.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`),"string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthb)for(let t=n;t0&&2===f.getWidth(this._activeBuffer.x-1)&&f.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let v=this._parser.precedingJoinState;for(let g=t;ga)if(h){const e=f;let t=this._activeBuffer.x-m;for(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),f=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),m>0&&f instanceof l.BufferLine&&f.copyCellsFrom(e,t,0,m,!1);t=0;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}else if(d&&(f.insertCells(this._activeBuffer.x,r-m,this._activeBuffer.getNullCell(u)),2===f.getWidth(a-1)&&f.setCellFromCodepoint(a-1,_.NULL_CELL_CODE,_.NULL_CELL_WIDTH,u)),f.setCellFromCodepoint(this._activeBuffer.x++,s,r,u),r>0)for(;--r;)f.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=v,this._activeBuffer.x0&&0===f.getWidth(this._activeBuffer.x)&&!f.hasContent(this._activeBuffer.x)&&f.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!w(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new m.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new g.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let h=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,u=e.params[0];return f=u,v=t?2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(d.convertEol):0:1===u?_(i.applicationCursorKeys):3===u?d.windowOptions.setWinLines?80===h?2:132===h?1:0:0:6===u?_(i.origin):7===u?_(i.wraparound):8===u?3:9===u?_("X10"===s):12===u?_(d.cursorBlink):25===u?_(!o.isCursorHidden):45===u?_(i.reverseWraparound):66===u?_(i.applicationKeypad):67===u?4:1e3===u?_("VT200"===s):1002===u?_("DRAG"===s):1003===u?_("ANY"===s):1004===u?_(i.sendFocus):1005===u?4:1006===u?_("SGR"===r):1015===u?4:1016===u?_("SGR_PIXELS"===r):1048===u?1:47===u||1047===u||1049===u?_(c===l):2004===u?_(i.bracketedPasteMode):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${v}$y`),!0;var f,v}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=f.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=e.params[0]||1;switch(t){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}const i=t%2==1;return this._optionsService.options.cursorBlink=i,!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!w(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(y.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(y.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(D(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,S.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.split(";");return!(t.length<2)&&(t[1]?this._createHyperlink(t[0],t[1]):!t[0]&&this._finishHyperlink())}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,S.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=k;let L=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(E=e,e=t,t=E),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function D(e){return 0<=e&&e<256}L=s([r(0,v.IBufferService)],L)},844:(e,t)=>{function i(e){for(const t of e)t.dispose();e.length=0}Object.defineProperty(t,"__esModule",{value:!0}),t.getDisposeArrayDisposable=t.disposeArray=t.toDisposable=t.MutableDisposable=t.Disposable=void 0,t.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.length=0}register(e){return this._disposables.push(e),e}unregister(e){const t=this._disposables.indexOf(e);-1!==t&&this._disposables.splice(t,1)}},t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}},t.toDisposable=function(e){return{dispose:e}},t.disposeArray=i,t.getDisposeArrayDisposable=function(e){return{dispose:()=>i(e)}}},1505:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},6114:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.getSafariVersion=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.isNode="undefined"!=typeof process&&"title"in process;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},6106:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;let i=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[]}clear(){this._array.length=0}insert(e){0!==this._array.length?(i=this._search(this._getKey(e)),this._array.splice(i,0,e)):this._array.push(e)}delete(e){if(0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(i=this._search(t),-1===i)return!1;if(this._getKey(this._array[i])!==t)return!1;do{if(this._array[i]===e)return this._array.splice(i,1),!0}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{yield this._array[i]}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{t(this._array[i])}while(++i=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},7226:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(6114);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=Date.now()+e;return{timeRemaining:()=>Math.max(0,t-Date.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},9282:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=void 0;const s=i(643);t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)}},3734:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return 50331648==(50331648&this.fg)}isBgRGB(){return 50331648==(50331648&this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return 0==(50331648&this.fg)}isBgDefault(){return 0==(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?50331648==(50331648&this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?0==(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},9092:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(6349),r=i(7226),n=i(3734),o=i(8437),a=i(4634),h=i(511),c=i(643),l=i(4863),d=i(7116);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=h.CellData.fromCharData([0,c.NULL_CELL_CHAR,c.NULL_CELL_WIDTH,c.NULL_CELL_CODE]),this._whitespaceCell=h.CellData.fromCharData([0,c.WHITESPACE_CELL_CHAR,c.WHITESPACE_CELL_WIDTH,c.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA));if(i.length>0){const s=(0,a.reflowLargerCreateNewLayout)(this.lines,i);(0,a.reflowLargerApplyNewLayout)(this.lines,s.layout),this._reflowLargerAdjustViewport(e,t,s.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);const l=this.ybase+this.y;if(l>=n&&l0&&(s.push({start:n+c.length+r,newLines:v}),r+=v.length),c.push(...v);let p=_.length-1,g=_[p];0===g&&(p--,g=_[p]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[p])break;if(c[p].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(p--,g=_[p]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;c--)if(a&&a.start>n+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(c--,a.newLines[e]);c++,e.push({index:n+1,amount:a.newLines.length}),h+=a.newLines.length,a=s[++o]}else this.lines.set(c,t[n--]);let c=0;for(let t=e.length-1;t>=0;t--)e[t].index+=c,this.lines.onInsertEmitter.fire(e[t]),c+=e[t].amount;const l=Math.max(0,i+r-this.lines.maxLength);l>0&&this.lines.onTrimEmitter.fire(l)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},8437:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(3734),r=i(511),n=i(643),o=i(482);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class h{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,o.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&s)+(0,o.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e,t,i,s){t=t??0,i=i??this.length,e&&(i=Math.min(i,this.getTrimmedLength())),s&&(s.length=0);let r="";for(;t>22||1}return s&&s.push(t),r}}t.BufferLine=h},4841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=void 0,t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},4634:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.getWrappedLineTrimmedLength=t.reflowSmallerGetNewLineLengths=t.reflowLargerApplyNewLayout=t.reflowLargerCreateNewLayout=t.reflowLargerGetLinesToRemove=void 0,t.reflowLargerGetLinesToRemove=function(e,t,s,r,n){const o=[];for(let a=0;a=a&&r0&&(e>d||0===l[e].getTrimmedLength());e--)v++;v>0&&(o.push(a+l.length-v),o.push(v)),a+=l.length-1}return o},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,h=0;for(;hc&&(o-=c,a++);const l=2===e[a].getWidth(o-1);l&&o--;const d=l?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},5295:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(8460),r=i(844),n=i(9092);class o extends r.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this.register(new s.EventEmitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this.register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new n.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new n.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},511:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(482),r=i(643),n=i(3734);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},643:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=256|t.DEFAULT_COLOR<<9,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},4863:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(8460),r=i(844);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.EventEmitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.disposeArray)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},7116:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},7399:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void 0;const s=i(2584),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:o.key=e.ctrlKey?"\b":s.C0.DEL,e.altKey&&(o.key=s.C0.ESC+o.key);break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"D",o.key===s.C0.ESC+"[1;3D"&&(o.key=s.C0.ESC+(i?"b":"[1;5D"))):o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"C",o.key===s.C0.ESC+"[1;3C"&&(o.key=s.C0.ESC+(i?"f":"[1;5C"))):o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"A",i||o.key!==s.C0.ESC+"[1;3A"||(o.key=s.C0.ESC+"[1;5A")):o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"B",i||o.key!==s.C0.ESC+"[1;3B"||(o.key=s.C0.ESC+"[1;5B")):o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=t?.[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o}},482:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=t.utf32ToString=t.stringFromCodePoint=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,h=0,c=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const h=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,l=h-o;for(;c=i)return 0;if(n=e[c++],128!=(192&n)){c--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===h?r<128?c--:t[a++]=r:3===h?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const l=i-4;let d=c;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(h=(31&s)<<6|63&r,h<128){d--;continue}t[a++]=h}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(h=(15&s)<<12|(63&r)<<6|63&n,h<2048||h>=55296&&h<=57343||65279===h)continue;t[a++]=h}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(h=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,h<65536||h>1114111)continue;t[a++]=h}}return a}}},225:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(1480),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],n=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let o;t.UnicodeV6=class{constructor(){if(this.version="6",!o){o=new Uint8Array(65536),o.fill(1),o[0]=0,o.fill(0,1,32),o.fill(0,127,160),o.fill(2,4352,4448),o[9001]=2,o[9002]=2,o.fill(2,11904,42192),o[12351]=1,o.fill(2,44032,55204),o.fill(2,63744,64256),o.fill(2,65040,65050),o.fill(2,65072,65136),o.fill(2,65280,65377),o.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},5981:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this.register(new s.EventEmitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||Date.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>Date.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,Date.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},5941:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.toRgbString=t.parseColor=void 0;const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`}},5770:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},6351:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(482),r=i(8742),n=i(5770),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},2015:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(844),r=i(8742),n=i(6242),o=i(6351);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(h,0,2,0),e.add(h,8,5,8),e.add(h,6,0,6),e.add(h,11,0,11),e.add(h,13,13,13),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this.register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this.register(new n.OscParser),this._dcsParser=this.register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingJoinState=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const c=this._escHandlers[this._collect<<8|r];let l=c?c.length-1:-1;for(;l>=0&&(s=c[l](),!0!==s);l--)if(s instanceof Promise)return this._preserveStack(4,c,l,n,i),s;l<0&&this._escHandlerFb(this._collect<<8|r),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(5770),r=i(482),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},8742:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},5741:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(3785),r=i(511);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},3785:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(511);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},8285:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(8771),r=i(8460),n=i(844);class o extends n.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this.register(new r.EventEmitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},7975:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},7090:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(8460),o=i(844),a=i(5295),h=i(2585);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let c=t.BufferService=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this.register(new n.EventEmitter),this.onResize=this._onResize.event,this._onScroll=this.register(new n.EventEmitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this.register(new a.BufferSet(e,this))}resize(e,t){this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t,i){const s=this.buffer;if(e<0){if(0===s.ydisp)return;this.isUserScrolling=!0}else e+s.ydisp>=s.ybase&&(this.isUserScrolling=!1);const r=s.ydisp;s.ydisp=Math.max(Math.min(s.ydisp+e,s.ybase),0),r!==s.ydisp&&(t||this._onScroll.fire(s.ydisp))}};t.BufferService=c=s([r(0,h.IOptionsService)],c)},7994:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},1753:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(2585),o=i(8460),a=i(844),h={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function c(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const l=String.fromCharCode,d={DEFAULT:e=>{const t=[c(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${l(t[0])}${l(t[1])}${l(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.x};${e.y}${t}`}};let _=t.CoreMouseService=class extends a.Disposable{constructor(e,t){super(),this._bufferService=e,this._coreService=t,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._onProtocolChange=this.register(new o.EventEmitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(h))this.addProtocol(e,h[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=_=s([r(0,n.IBufferService),r(1,n.ICoreService)],_)},6975:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(1439),o=i(8460),a=i(844),h=i(2585),c=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0});let d=t.CoreService=class extends a.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this.register(new o.EventEmitter),this.onData=this._onData.event,this._onUserInput=this.register(new o.EventEmitter),this.onUserInput=this._onUserInput.event,this._onBinary=this.register(new o.EventEmitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this.register(new o.EventEmitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}reset(){this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d=s([r(0,h.IBufferService),r(1,h.ILogService),r(2,h.IOptionsService)],d)},9074:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(8055),r=i(8460),n=i(844),o=i(6106);let a=0,h=0;class c extends n.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new o.SortedList((e=>e?.marker.line)),this._onDecorationRegistered=this.register(new r.EventEmitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this.register(new r.EventEmitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this.register((0,n.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new l(e);if(t){const e=t.marker.onDispose((()=>t.dispose()));t.onDispose((()=>{t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())})),this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){let s=0,r=0;for(const n of this._decorations.getKeyIterator(t))s=n.options.x??0,r=s+(n.options.width??1),e>=s&&e{a=t.options.x??0,h=a+(t.options.width??1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(2585),r=i(8343);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7866:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.traceCall=t.setTraceLogger=t.LogService=void 0;const n=i(844),o=i(2585),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let h,c=t.LogService=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this.register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),h=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tJSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return h.trace(`GlyphRenderer#${s.name} return`,t),t}}},7302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(8460),r=i(844),n=i(6114);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"courier-new, courier, monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},rescaleOverlappingGlyphs:!1,rightClickSelectsWord:n.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRulerWidth:0};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends r.Disposable{constructor(e){super(),this._onOptionChange=this.register(new s.EventEmitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this.register((0,r.toDisposable)((()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null})))}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},2660:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(2585);let o=t.OscLinkService=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o=s([r(0,n.IBufferService)],o)},8343:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createDecorator=t.getServiceDependencies=t.serviceRegistry=void 0;const i="di$target",s="di$dependencies";t.serviceRegistry=new Map,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r.toString=()=>e,t.serviceRegistry.set(e,r),r}},2585:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(8343);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},1480:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8460),r=i(225);class n{static extractShouldJoin(e){return 0!=(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.EventEmitter,this.onChange=this._onChange.event;const e=new r.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let r=0;r=s)return t+this.wcwidth(o);const i=e.charCodeAt(r);56320<=i&&i<=57343?o=1024*(o-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(o,i);let h=n.extractWidth(a);n.extractShouldJoin(a)&&(h-=n.extractWidth(i)),t+=h,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=n}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(9042),r=i(3236),n=i(844),o=i(5741),a=i(8285),h=i(7975),c=i(7090),l=["cols","rows"];class d extends n.Disposable{constructor(e){super(),this._core=this.register(new r.Terminal(e)),this._addonManager=this.register(new o.AddonManager),this._publicOptions={...this._core.options};const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(l.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new h.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new c.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this.register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._checkProposedApi(),this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return t}_verifyIntegers(...e){for(const t of e)if(t===1/0||isNaN(t)||t%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(const t of e)if(t&&(t===1/0||isNaN(t)||t%1!=0||t<0))throw new Error("This API only accepts positive integers")}}e.Terminal=d})(),s})())); +//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt new file mode 100644 index 0000000000..10c75d8e5b --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt @@ -0,0 +1,279 @@ +package dev.nodeterm.android + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.core.content.ContextCompat +import dev.nodeterm.android.ui.AddSshHostScreen +import dev.nodeterm.android.ui.AllComputersScreen +import dev.nodeterm.android.ui.HostScreen +import dev.nodeterm.android.ui.HostsScreen +import dev.nodeterm.android.ui.NodetermTheme +import dev.nodeterm.android.ui.PairScreen +import dev.nodeterm.android.ui.SettingsScreen +import dev.nodeterm.android.ui.SourceControlScreen +import dev.nodeterm.android.ui.TerminalScreen +import dev.nodeterm.protocol.model.BackStack + +/** The screens. A plain back stack: eight destinations, one deep link (the pairing URL). */ +sealed interface Route { + data object Hosts : Route + data class PairHost(val code: String? = null) : Route + /** Add a computer by its SSH address, with no pairing code (audit A27). */ + data object AddSshHost : Route + data object Settings : Route + data class Host(val hostId: String, val tab: Int = 0) : Route + data class Terminal(val hostId: String, val nodeId: String, val title: String) : Route + /** One project's source control on that computer (audit A29). */ + data class SourceControl(val hostId: String, val projectId: String) : Route + /** Every paired computer's Inbox and Usage, merged (audit A55). Names no computer of its own. */ + data object AllComputers : Route +} + +/** + * The back stack. Each entry has its own key (audit A43): AppContent files the entry's saved UI state + * (the Host screen's tab, scroll positions, the Board's project, the Inbox's archive toggle) under it, + * so the screen below a terminal comes back as it was left. The rules live in [BackStack] (protocol, + * tested); this is its observable holder. Used from the main thread only. + */ +class Navigator(initial: BackStack) { + constructor(initial: Route) : this(BackStack.of(listOf(initial), Route.Hosts)) + + private var backStack by mutableStateOf(initial) + + val size: Int get() = backStack.size + val entries: List> get() = backStack.entries + + /** The showing entry: its route, and the key its saved UI state is filed under. */ + val top: BackStack.Entry get() = backStack.entries.last() + + fun push(route: Route) { + backStack = backStack.push(route) + } + + fun pop(): Boolean { + backStack = backStack.pop() ?: return false + return true + } + + fun replaceAll(route: Route) { + backStack = backStack.replaceAll(route) + } + + /** Keeps the entries [keep] accepts, each with its saved state; the computers list when none is left. */ + fun retain(keep: (Route) -> Boolean) { + backStack = backStack.retain(Route.Hosts, keep = keep) + } + + /** The keys of the entries that left the stack since the last call. Their saved state is to be dropped. */ + fun takeRetired(): List { + val keys = backStack.retired + if (keys.isNotEmpty()) backStack = backStack.withoutRetired() + return keys + } + + companion object { + /** + * Saves the back stack across activity recreation (a density, font-scale or locale change, + * or process death) — it used to reset to the computers list (audit A22). Each route is a + * list of strings, saved with its entry's key (audit A43); an entry that no longer decodes is + * dropped, never guessed. + */ + val Saver: Saver = Saver( + save = { nav -> nav.backStack.encode(::encode) }, + restore = { raw -> Navigator(BackStack.decode(raw, Route.Hosts, route = ::decode)) } + ) + + private fun encode(r: Route): List = when (r) { + Route.Hosts -> listOf("hosts") + is Route.PairHost -> listOfNotNull("pair", r.code) + Route.AddSshHost -> listOf("addssh") + Route.Settings -> listOf("settings") + is Route.Host -> listOf("host", r.hostId, r.tab.toString()) + is Route.Terminal -> listOf("terminal", r.hostId, r.nodeId, r.title) + is Route.SourceControl -> listOf("git", r.hostId, r.projectId) + // A new name, not a new shape: a stack saved before this route existed decodes as before, + // and a build without it drops the entry (decode answers null) instead of guessing. + Route.AllComputers -> listOf("all") + } + + private fun decode(parts: List): Route? = when (parts.firstOrNull()) { + "hosts" -> Route.Hosts + // A pairing code is single-use: coming back to it would only fail. Drop it. + "pair" -> null + // Unlike a pairing code nothing in it is single-use: the form comes back as it was typed. + "addssh" -> Route.AddSshHost + "settings" -> Route.Settings + "host" -> parts.getOrNull(1)?.let { Route.Host(it, parts.getOrNull(2)?.toIntOrNull() ?: 0) } + "terminal" -> if (parts.size == 4) Route.Terminal(parts[1], parts[2], parts[3]) else null + "git" -> if (parts.size == 3) Route.SourceControl(parts[1], parts[2]) else null + "all" -> Route.AllComputers + else -> null + } + } +} + +class MainActivity : ComponentActivity() { + /** A `nodeterm://pair?code=` link that arrived (at launch or while running) and awaits the UI. */ + private var incomingPairCode by mutableStateOf(null) + + private fun takePairLink(intent: Intent?) { + val data = intent?.data ?: return + if (intent.action == Intent.ACTION_VIEW && data.scheme == "nodeterm" && data.host == "pair") { + incomingPairCode = data.toString() + } + } + + /** A notification tap (at launch or while running) that awaits the UI. */ + private var incomingTap by mutableStateOf(null) + + /** The session a notification names: [EXTRA_HOST_ID], and the node it is about (audit A25). */ + private fun tapOf(intent: Intent?): NotificationTap? { + val hostId = intent?.getStringExtra(EXTRA_HOST_ID) ?: return null + val nodeId = intent.getStringExtra(EXTRA_NODE_ID)?.takeIf { it.isNotBlank() } + return NotificationTap(hostId, nodeId, intent.getStringExtra(EXTRA_NODE_TITLE)) + } + + /** + * A live activity gets later intents HERE, not in onCreate (it is `singleTask`): a notification + * tapped while the app sat in the background used to open whatever screen was last showing + * instead of that computer's Inbox (audit A11/A19), and now its session (audit A25). + */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + takePairLink(intent) + tapOf(intent)?.let { incomingTap = it } + } + + private val notificationPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* the Settings switch reflects it */ } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val graph = NodetermApp.graph(this) + // Asked on a fresh start only — not on every rotation or recreation (audit A21). Later asks + // come from the Settings switch, which also offers the system settings once Android stops + // showing the dialog. + if (savedInstanceState == null && Build.VERSION.SDK_INT >= 33 && graph.hosts.notificationsEnabled && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + // The launch intent (a notification tap, a pairing link) is applied on a FRESH start only: on a + // recreation the saved back stack already reflects it, and re-applying it would push the + // same screen again (audit A22). + val fresh = savedInstanceState == null + val launchTap = if (fresh) tapOf(intent) else null + if (fresh) takePairLink(intent) + setContent { + NodetermTheme { + val nav = rememberSaveable(saver = Navigator.Saver) { + Navigator(Route.Hosts).also { n -> + if (launchTap != null && graph.hosts.get(launchTap.hostId) != null) n.openTap(launchTap) + } + } + // A restored stack may name a computer that was forgotten meanwhile. + LaunchedEffect(Unit) { + nav.retain { r -> + when (r) { + is Route.Host -> graph.hosts.get(r.hostId) != null + is Route.Terminal -> graph.hosts.get(r.hostId) != null + is Route.SourceControl -> graph.hosts.get(r.hostId) != null + else -> true + } + } + } + val tap = incomingTap + LaunchedEffect(tap) { + if (tap != null) { + incomingTap = null + if (graph.hosts.get(tap.hostId) != null) { + nav.replaceAll(Route.Hosts) + nav.openTap(tap) + } + } + } + val code = incomingPairCode + LaunchedEffect(code) { + if (code != null) { + incomingPairCode = null + nav.push(Route.PairHost(code)) + } + } + AppContent(nav) + } + } + } + + companion object { + const val EXTRA_HOST_ID = "hostId" + /** The session a notification is about; its tap opens that terminal (audit A25). */ + const val EXTRA_NODE_ID = "nodeId" + /** That session's name, for the terminal's title until the listing names it. */ + const val EXTRA_NODE_TITLE = "nodeTitle" + } +} + +/** What a notification's tap opens: [nodeId]'s terminal on [hostId], or that computer's Inbox without one. */ +private data class NotificationTap(val hostId: String, val nodeId: String?, val title: String?) + +/** + * Opens what a notification tap names: the session's terminal (audit A25), with that computer's Inbox + * under it, where the event is listed, so Back lands where the A11/A19 tap used to open. + */ +private fun Navigator.openTap(tap: NotificationTap) { + push(Route.Host(tap.hostId, tab = 2)) + val node = tap.nodeId ?: return + push(Route.Terminal(tap.hostId, node, tap.title?.takeIf { it.isNotBlank() } ?: "Session")) +} + +@Composable +private fun AppContent(nav: Navigator) { + // Only the top entry is composed, so without a holder a screen's saved state died the moment + // another was pushed on it: back from a terminal, the Host screen was on its first tab, scrolled + // to the top, on the Board's first project (audit A43). Each entry's state is filed under its own + // key, which the saved back stack keeps across recreation, like the holder keeps the state. + val saved = rememberSaveableStateHolder() + // An entry that left the stack never comes back (a new push gets a new key): drop its state. + val graph = NodetermApp.graph(LocalContext.current) + SideEffect { + nav.takeRetired().forEach { saved.removeState(it) } + graph.terminalDrafts.retain(nav.entries.filter { + val route = it.value + route is Route.Terminal && graph.hosts.get(route.hostId) != null + }.mapTo(mutableSetOf()) { it.key }) + } + BackHandler(enabled = nav.size > 1) { nav.pop() } + val top = nav.top + // Keyed per ENTRY, not per route: a Host screen for another computer, or for the same one opened + // again (a notification → Inbox), starts from its own route, never from the showing screen's state. + saved.SaveableStateProvider(top.key) { + when (val r = top.value) { + Route.Hosts -> HostsScreen(nav) + is Route.PairHost -> PairScreen(nav, r.code) + Route.AddSshHost -> AddSshHostScreen(nav) + Route.Settings -> SettingsScreen(nav) + is Route.Host -> HostScreen(nav, r.hostId, r.tab) + is Route.Terminal -> TerminalScreen(nav, top.key, r.hostId, r.nodeId, r.title) + is Route.SourceControl -> SourceControlScreen(nav, r.hostId, r.projectId) + Route.AllComputers -> AllComputersScreen(nav) + } + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt b/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt new file mode 100644 index 0000000000..e4303afa32 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/NodetermApp.kt @@ -0,0 +1,80 @@ +package dev.nodeterm.android + +import android.app.Application +import android.content.Context +import androidx.compose.ui.text.input.TextFieldValue +import dev.nodeterm.protocol.model.TerminalDrafts +import dev.nodeterm.android.conn.ConnectionManager +import dev.nodeterm.android.data.HostStore +import dev.nodeterm.android.data.SecureStore +import dev.nodeterm.android.notify.InboxNotifier +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.host.RelayApprovalGate +import dev.nodeterm.protocol.model.OnScreen +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.pairing.SshIdentity +import dev.nodeterm.protocol.secure.PhoneIdentity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.security.Security + +/** The app's singletons. Constructed once, in [NodetermApp.onCreate]. */ +class AppGraph(context: Context) { + val secure = SecureStore(context) + val hosts = HostStore(context) + + /** + * The phone's relay identity: the box key and the relay deviceId, kept together — a new box key + * always comes with a new deviceId (audit A51). Read the deviceId as `identity.deviceId()`. + */ + val identity: PhoneIdentity = secure.phoneIdentity(hosts.identityStorage) + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val terminalDrafts = TerminalDrafts(TextFieldValue()) + val connections = ConnectionManager(this) + + /** Who may dial a computer's relay, and when (never a dialog-raising first handshake from the background). */ + val relayGate = RelayApprovalGate(hosts::relayApproved, hosts::setRelayApproved) + + /** The phone's persistent relay identity — the key every paired desktop PINS. */ + val boxKeys: BoxKeyPair by lazy { BoxKeyPair.fromSecretKey(identity.boxSecret()) } + + /** The phone's SSH identity (Ed25519); its public half is in each computer's authorized_keys. */ + val sshIdentity: SshIdentity by lazy { SshIdentity.fromSeed(secure.getOrCreate32(SecureStore.SSH_SEED)) } + + private val appContext: Context = context.applicationContext + + /** + * A fresh listing of [hostId] arrived (HostSession.refreshNow): notify about its new Inbox events, + * minus what [onScreen] shows (audit A73). One path for the 8 s refresh of the computer on screen + * and the background check alike. + */ + fun announce(hostId: String, snapshot: ProjectsSnapshot, onScreen: OnScreen, quiet: Boolean) { + val host = hosts.get(hostId) ?: return + InboxNotifier.announce(appContext, host, snapshot, onScreen, quiet) + } +} + +class NodetermApp : Application() { + lateinit var graph: AppGraph + private set + + override fun onCreate() { + super.onCreate() + // Android ships a stripped "BC" provider under the same name; sshj asks for the "BC" provider + // BY NAME for its ciphers and key exchanges, so replace it before any SSH connection is made. + // Appended, not inserted first: nothing else in the app (TLS, the Keystore) should start + // resolving algorithms through it. + Security.removeProvider("BC") + Security.addProvider(BouncyCastleProvider()) + graph = AppGraph(this) + InboxNotifier.createChannels(this) + InboxNotifier.schedule(this, graph.hosts.notificationsEnabled) + } + + companion object { + fun graph(context: Context): AppGraph = (context.applicationContext as NodetermApp).graph + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt new file mode 100644 index 0000000000..89bfe4cf77 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt @@ -0,0 +1,765 @@ +package dev.nodeterm.android.conn + +import dev.nodeterm.android.AppGraph +import dev.nodeterm.android.data.RoutePreference +import dev.nodeterm.android.data.SecureStore +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.ConnectionUsers +import dev.nodeterm.protocol.host.ForegroundRefresh +import dev.nodeterm.protocol.host.HostLifetime +import dev.nodeterm.protocol.host.HostSessionRegistry +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.InboxNotificationActions +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.host.ListingFailure +import dev.nodeterm.protocol.host.ManagedSessionCreation +import dev.nodeterm.protocol.host.ManagedSessionRefusedException +import dev.nodeterm.protocol.host.RelayApprovalGate +import dev.nodeterm.protocol.host.RelayApprovalGate.Trigger +import dev.nodeterm.protocol.host.RelayConnectStatus +import dev.nodeterm.protocol.host.RelayConnector +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.OnScreen +import dev.nodeterm.protocol.model.OnScreenTracker +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.SshTerminalCreation +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.pairing.RelayBlock +import dev.nodeterm.protocol.pairing.RelayPairingProof +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.relay.RelayApi +import dev.nodeterm.protocol.ssh.HostKeyPin +import dev.nodeterm.protocol.ssh.LanRefresh +import dev.nodeterm.protocol.ssh.NothingFoundException +import dev.nodeterm.protocol.ssh.SshFallback +import dev.nodeterm.protocol.ssh.SshTerminalCreationRefusedException +import dev.nodeterm.protocol.ssh.SshHostConnection +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean + +sealed interface ConnState { + data object Idle : ConnState + data class Connecting(val detail: String) : ConnState + /** The desktop is showing its approval dialog; the human compares this code. */ + data class AwaitingApproval(val sas: String) : ConnState + data class Connected(val kind: TransportKind) : ConnState + data class Failed(val message: String) : ConnState + + /** + * Whether this state leaves a failed listing ([HostSession.lastError]) to the screen to show. Not + * while connecting or waiting for approval (an attempt is under way) and not [Failed] (it carries + * its own message). [Idle] does: a listing that fails with an unexpected error drops the connection + * ([HostSession.refreshNow]) and a background check closes it when it ends, and both leave Idle + * with the error standing and the cached cards on screen (the review of A55). + */ + val showsListError: Boolean get() = this is Connected || this == Idle +} + +/** + * One paired computer's live connection and latest listing. Route choice mirrors the iOS app: the + * direct-SSH leg first when the computer installed a key for us (it is the LAN, it is fast, and it + * needs no relay), the E2EE relay when that fails or the computer is relay-only (Windows). + */ +class HostSession(val hostId: String, private val graph: AppGraph) { + private val scope: CoroutineScope get() = graph.scope + private val _state = MutableStateFlow(ConnState.Idle) + val state: StateFlow = _state.asStateFlow() + private val _snapshot = MutableStateFlow(ProjectsSnapshot.EMPTY) + val snapshot: StateFlow = _snapshot.asStateFlow() + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError.asStateFlow() + private val _sshWarning = MutableStateFlow(null) + + /** + * Why the current connection is NOT the direct-SSH one although the route would have preferred + * it: the server at the paired address presented a different host key, so the Auto route went on + * to the relay (audit A49/A74). Set per connect; shown while connected, so a changed key never + * disappears behind a relay that worked. + */ + val sshWarning: StateFlow = _sshWarning.asStateFlow() + + private val lifetime = HostLifetime() + @Volatile private var connectionLease: HostLifetime.Lease? = null + private val mutex = Mutex() + @Volatile private var conn: HostConnection? = null + + /** The screens that show this computer and the background jobs on its connection (review of A25). */ + private val users = ConnectionUsers() + private val foreground = ForegroundRefresh( + scope, users, { disconnect() }, + refresh = { trigger -> refreshNow(trigger) }, + pause = { delay(POLL_MS) }, + ) + + val connection: HostConnection? get() = conn + + // Host-owned, rather than dialog-owned: dismissal/background must not cancel a sent creation. + val sshTerminalCreation = SshTerminalCreation(graph.scope, { request -> + createAndRefreshSshTerminal(request.nodeId, request.cwd) + }, definitiveFailure = { it is IllegalArgumentException || it is SshTerminalCreationRefusedException }) + + val managedSessionCreation = ManagedSessionCreation(graph.scope, graph.hosts.managedCreationStorage(hostId) { lifetime.active }, + prepare = { choice -> inBackground { + val ssh = ensureConnected(Trigger.USER) as? SshHostConnection + ?: throw ManagedSessionRefusedException("Connect over SSH to create this managed session.") + ssh.prepareManagedSession(choice) + } }, + create = { request -> inBackground { + try { + val ssh = ensureConnected(Trigger.BACKGROUND) as? SshHostConnection + ?: throw ManagedSessionRefusedException("The SSH route changed before creation was sent. Refresh and try again.") + ssh.createManagedSession(request) + } finally { refreshNow(Trigger.BACKGROUND) } + } }) + + suspend fun createAndRefreshSshTerminal(nodeId: String, cwd: String?) = inBackground { + try { + val ssh = ensureConnected(Trigger.BACKGROUND) as? SshHostConnection + ?: throw HostException("Connect over SSH to create a plain terminal.") + ssh.createTerminal(nodeId, cwd) + } finally { + // Also re-list after a lost reply, and when the user has already closed the dialog. + // Cancellation still propagates; refreshNow handles ordinary listing errors itself. + refreshNow(Trigger.BACKGROUND) + } + } + + + /** A screen is showing this computer right now (so its connection is worth keeping open). */ + val isWatched: Boolean get() = users.watched + + /** + * Runs [block] as a background user of this computer's connection: the periodic Inbox check, or an + * answer given from a notification (the review of audit A25). They share the connection with each + * other and with the screens, so it is closed when [block] ends only if no screen shows this + * computer and no other background job still runs on it ([ConnectionUsers]): don't hold a socket + * open in the background for a screen nobody is looking at, but never close it under another job. + */ + suspend fun inBackground(block: suspend () -> T): T = users.hold({ disconnect() }, block) + + /** + * What of this computer is on screen: its Inbox tab, and the sessions open in a terminal, as far + * as each terminal shows its pane. Each listing announces its new Inbox events except those + * (audit A73, see [refreshNow]). + */ + val onScreen = OnScreenTracker() + + suspend fun ensureConnected(trigger: Trigger = Trigger.AUTO): HostConnection { + val lease = lifetime.capture() + conn?.let { return it } + return mutex.withLock { + lifetime.requireCurrent(lease) + conn ?: connectLocked(trigger, lease) + } + } + + private suspend fun connectLocked(trigger: Trigger, lease: HostLifetime.Lease): HostConnection { + val host = graph.hosts.get(hostId) ?: throw HostException("This computer is no longer paired.") + val route = graph.hosts.route(hostId) + val errors = ArrayList() + var sshWarning: String? = null + var refusedHostKey: String? = null + + if (route != RoutePreference.RELAY_ONLY && host.sshAvailable) { + _state.value = ConnState.Connecting(if (host.manual) "Connecting over SSH…" else "Connecting on your network…") + // The blocking dial finishes even when this coroutine is cancelled meanwhile (the poll + // job stops when a screen goes away), and withContext then drops its result: close it, + // or it stays open, keep-alive and all, for the life of the process (audit A20). + var dialed: SshHostConnection? = null + try { + val ssh = lifetime.dial(lease) { withContext(Dispatchers.IO) { + SshHostConnection.connect( + host.host, host.port, host.user, graph.sshIdentity, pinFor(host, lease), + connectTimeoutMs = if (route == RoutePreference.AUTO) 4_000 else 10_000, + profilePath = host.sshProfilePath + ).also { dialed = it } + } } + lifetime.publish(lease) { + _sshWarning.value = null + sshRefusal = null + } + adopt(ssh, lease) + adoptInBackground(ssh) + return ssh + } catch (e: kotlinx.coroutines.CancellationException) { + dialed?.let { c -> scope.launch(Dispatchers.IO) { runCatching { c.close() } } } + lifetime.whenCurrent(lease) { _state.value = ConnState.Idle } + throw e + } catch (e: Exception) { + lifetime.requireCurrent(lease) + // A changed host key is refused for SSH and said out loud, but in Auto it does not + // stop the relay leg, which authenticates the computer on its own (audit A49/A74): + // most often another machine simply has the paired address now. "Only on my + // network" has nothing else to try, so there it stops. + // A computer added by its SSH address has nothing else to try either (audit A27). + when (val next = SshFallback.afterFailure(e, route != RoutePreference.SSH_ONLY, relayConfigured(host), addedOverSsh = host.manual)) { + is SshFallback.Next.Stop -> { + _sshWarning.value = null + _state.value = ConnState.Failed(next.message) + throw HostException(next.message) + } + is SshFallback.Next.TryRelay -> { + errors += next.error + sshWarning = next.warning + refusedHostKey = next.refusedHostKey + } + } + } + } + + if (route != RoutePreference.SSH_ONLY) { + val relay = host.relay + val token = graph.secure.getString(SecureStore.relayTokenKey(host.id)) + val hostKey = host.relayHostKeyB64 + val decision = if (relay != null && token != null && hostKey != null) lifetime.publish(lease) { graph.relayGate.decide(hostId, trigger) } else null + if (decision is RelayApprovalGate.Decision.Skip) { + errors += "Through the relay: ${decision.reason}" + } else if (relay != null && token != null && hostKey != null) { + val requireApproved = (decision as? RelayApprovalGate.Decision.Dial)?.requireApproved == true + _state.value = ConnState.Connecting("Connecting through the relay…") + try { + val connected = dialRelay(host, relay, token, hostKey, requireApproved, lease) { st -> + lifetime.whenCurrent(lease) { when (st) { + is RelayConnectStatus.AwaitingApproval -> _state.value = ConnState.AwaitingApproval(st.sas) + RelayConnectStatus.Handshaking -> _state.value = ConnState.Connecting("Verifying your computer…") + RelayConnectStatus.MintingToken -> Unit + } } + } + try { + lifetime.publish(lease) { + graph.relayGate.onConnected(hostId) + _snapshot.value = connected.first + _sshWarning.value = sshWarning + sshRefusal = refusedHostKey?.let { SshRefusal(connected.connection, it) } + } + adopt(connected.connection, lease) + refreshLanLeg(connected.connection, connected.first) + } catch (e: kotlinx.coroutines.CancellationException) { + scope.launch(Dispatchers.IO) { runCatching { connected.connection.close() } } + throw e + } + return connected.connection + } catch (e: kotlinx.coroutines.CancellationException) { + // A cancelled dial is not a failed one: no "offline", no error text (A20). + lifetime.whenCurrent(lease) { _state.value = ConnState.Idle } + throw e + } catch (e: Exception) { + lifetime.publish(lease) { graph.relayGate.onFailed(hostId, e) } + errors += "Through the relay: ${e.message ?: e.javaClass.simpleName}" + } + } else if (route == RoutePreference.RELAY_ONLY || !host.sshAvailable) { + errors += "Remote access isn't set up for this computer yet. In nodeterm on the computer, open " + + "Settings → Phone and turn on remote access, then pair again — or connect once on the same network." + } + } + + val msg = errors.joinToString("\n").ifEmpty { "Couldn't connect." } + lifetime.publish(lease) { + _sshWarning.value = null // the failure message already carries it + _state.value = ConnState.Failed(msg) + } + throw HostException(msg) + } + + /** + * The phone holds a relay leg for [host]: the same three facts the relay block needs to dial. Never + * for a computer added by its SSH address, which has none (audit A27). + * + * The token is asked by PRESENCE ([SecureStore.hasRelayToken]: the preferences alone, no Keystore + * decrypt, no waiting on the store's lock), never by reading it: the screens ask [route] while + * composing, on every listing (audit A47). Reading it there also answered "no relay leg" during a + * keystore hiccup, and the controls then told a user whose remote access is on to turn it on. + * What dials ([connectLocked], [viaRelay]) still reads the token itself. + */ + private fun relayConfigured(host: PairedHost): Boolean = + !host.manual && host.relay != null && host.relayHostKeyB64 != null && graph.secure.hasRelayToken(host.id) + + private suspend fun dialRelay( + host: PairedHost, + relay: RelayBlock, + token: String, + hostKey: String, + requireApproved: Boolean, + lease: HostLifetime.Lease, + onStatus: (RelayConnectStatus) -> Unit + ): RelayConnector.Connected { + // Keep the produced resource before the cancellable async handoff: cancellation may + // discard a successful result before the caller has a reference it can close. + var dialed: RelayConnector.Connected? = null + try { + return lifetime.dial(lease) { + lifetime.requireCurrent(lease) + val join = RelayApi(graph.hosts.apiBase).join(token, relay.hostId) + lifetime.requireCurrent(lease) + val deviceKeys = graph.boxKeys + RelayConnector.connect( + relayUrl = relay.relayEndpoint, + token = join.pairingToken, + deviceKeys = deviceKeys, + hostPublicKeyB64 = hostKey, + requireApproved = requireApproved, + legacyPairing = legacyRelayPairing(host, hostKey, deviceKeys, lease), + onStatus = onStatus + ).also { dialed = it } + } + } catch (e: kotlinx.coroutines.CancellationException) { + dialed?.let { connected -> scope.launch(Dispatchers.IO) { runCatching { connected.connection.close() } } } + throw e + } + } + + /** No get-or-create: a relay-only upgrade cannot replace a missing SSH identity to prove it. */ + private fun legacyRelayPairing(host: PairedHost, hostKey: String, keys: BoxKeyPair, lease: HostLifetime.Lease): RelayPairingProof.Context { + return RelayPairingProof.Context(host, hostKey, keys.publicKeyB64, + retainedSshSeed = { graph.secure.getBytes(SecureStore.SSH_SEED) }, + requireCurrent = { + lifetime.requireCurrent(lease) + val current = graph.hosts.currentHost(host.id, host.hostKeyB64) { lifetime.isCurrent(lease) } + ?: throw kotlinx.coroutines.CancellationException("This computer pairing changed.") + if (current.manual || current.relayHostKeyB64 != hostKey || graph.boxKeys.publicKeyB64 != keys.publicKeyB64) { + throw kotlinx.coroutines.CancellationException("This computer's relay identity changed.") + } + }) + } + + /** A relay connection held NEXT TO a direct-SSH one, for what SSH must not do (see [viaRelay]). */ + @Volatile private var sideRelay: HostConnection? = null + private val sideMutex = Mutex() + + /** + * Whether the relay leg can be opened next to the primary connection. Read fresh each time: a + * late adoption ([adoptRelayIfAdvertised]) mints the token while connected over SSH, and each + * listing over SSH says whether the computer advertises its relay right now (remote access may + * have been turned off since the phone got its token). Cheap enough to ask while composing: no + * secret is decrypted (see [relayConfigured]). + */ + fun relayLeg(): LegRouting.RelayLeg { + val host = graph.hosts.get(hostId) ?: return LegRouting.RelayLeg.NOT_SET_UP + return LegRouting.relayLeg( + relayConfigured = relayConfigured(host), + sshOnlyRoute = graph.hosts.route(hostId) == RoutePreference.SSH_ONLY, + addedOverSsh = host.manual, + relayAdvertised = (conn as? SshHostConnection)?.relayAdvertised + ) + } + + /** + * This computer can be reached without a FIRST relay handshake (audit A25): over its SSH leg, or + * through a relay that has already approved this phone. What a notification's answer may use: the + * user tapped, but is not looking at the app to compare the desktop's approval code (audit A05). + */ + fun reachableQuietly(): Boolean { + val host = graph.hosts.get(hostId) ?: return false + return InboxNotificationActions.reachableQuietly( + sshLeg = host.sshAvailable && graph.hosts.route(hostId) != RoutePreference.RELAY_ONLY, + relayLeg = relayLeg() == LegRouting.RelayLeg.AVAILABLE, + relayApproved = graph.hosts.relayApproved(hostId) + ) + } + + /** Which leg answers [cap] right now ([LegRouting.route]): the one decision the UI and [connectionFor] share. */ + fun route(cap: Capability): LegRouting.Leg { + val c = conn + return LegRouting.route(cap, c?.kind, c?.capabilities, relayLeg()) + } + + /** + * [route] for something in [project]: a project another desktop drives over SSH is that + * desktop's, which neither leg of this computer reaches ([LegRouting.forProject], audit A27). + */ + fun route(cap: Capability, project: ProjectInfo?): LegRouting.Leg = LegRouting.forProject(cap, project, route(cap)) + + /** + * A connection that can do [cap] (audit A26): the primary one when it can, else the relay leg + * opened next to it. For a USER's action — the relay dial goes through [RelayApprovalGate] with + * [trigger], so a background caller never makes a first relay handshake. Throws a + * [HostException] carrying the reason when neither leg can, which includes a [project] another + * desktop drives over SSH (A27). + */ + suspend fun connectionFor( + cap: Capability, + trigger: Trigger = Trigger.USER, + onStatus: (RelayConnectStatus) -> Unit = {}, + project: ProjectInfo? = null + ): HostConnection { + val primary = ensureConnected(trigger) + return when (val leg = route(cap, project)) { + LegRouting.Leg.Primary -> primary + LegRouting.Leg.Relay -> viaRelay(trigger, onStatus) + is LegRouting.Leg.Unavailable -> throw HostException(leg.reason) + } + } + + private val _relayApproval = MutableStateFlow(null) + + /** + * The approval code while the relay leg next to an SSH connection waits for the desktop's + * dialog (its first dial on a desktop that has not pinned this phone): the host screen shows it, + * since the board or New session that asked has no screen of its own for it. + */ + val relayApproval: StateFlow = _relayApproval.asStateFlow() + + /** + * A relay connection for one action the direct-SSH transport refuses + * ([dev.nodeterm.protocol.host.NeedsRelayException]): a session that is not running (creating it + * over SSH would leave it without its hook environment — audit A08), a node of the desktop's + * SSH projects, which the desktop reaches on its host (audit A09), or a verb only nodeterm the + * app serves (board writes, a new session, node actions, git — audit A26, via [connectionFor]). + * The primary connection when it already IS the relay; otherwise one opened next to it and kept + * until [disconnect]. [trigger] goes to [RelayApprovalGate]: a user's tap releases a held + * approval and, the first time, the desktop's approval code is reported through [onStatus] (and + * [relayApproval]); a background caller never makes a first handshake that would raise the + * dialog (only one with a computer whose pairing answered `relayApproved`, audit A07-late). + */ + suspend fun viaRelay(trigger: Trigger = Trigger.USER, onStatus: (RelayConnectStatus) -> Unit = {}): HostConnection { + val lease = lifetime.capture() + conn?.takeIf { it.kind == TransportKind.RELAY }?.let { return it } + sideRelay?.let { return it } + return sideMutex.withLock { + lifetime.requireCurrent(lease) + sideRelay ?: run { + val host = graph.hosts.get(hostId) ?: throw HostException("This computer is no longer paired.") + val relay = host.relay + val token = graph.secure.getString(SecureStore.relayTokenKey(host.id)) + val hostKey = host.relayHostKeyB64 + if (relay == null || token == null || hostKey == null || graph.hosts.route(hostId) == RoutePreference.SSH_ONLY) { + throw HostException("Remote access isn't set up for this computer. Open the session in nodeterm on the computer instead.") + } + val requireApproved = when (val d = lifetime.publish(lease) { graph.relayGate.decide(hostId, trigger) }) { + is RelayApprovalGate.Decision.Skip -> throw HostException(d.reason) + is RelayApprovalGate.Decision.Dial -> d.requireApproved + } + val connected = try { + dialRelay(host, relay, token, hostKey, requireApproved = requireApproved, lease = lease) { st -> + lifetime.whenCurrent(lease) { + _relayApproval.value = (st as? RelayConnectStatus.AwaitingApproval)?.sas + onStatus(st) + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + lifetime.publish(lease) { graph.relayGate.onFailed(hostId, e) } + throw e + } finally { + lifetime.whenCurrent(lease) { _relayApproval.value = null } + } + val c = connected.connection + try { + lifetime.publish(lease) { + graph.relayGate.onConnected(hostId) + c.setOnClosed { lifetime.whenCurrent(lease) { if (sideRelay === c) sideRelay = null } } + sideRelay = c + } + } catch (e: kotlinx.coroutines.CancellationException) { + scope.launch(Dispatchers.IO) { runCatching { c.close() } } + throw e + } + c + } + } + } + + private fun adopt(c: HostConnection, lease: HostLifetime.Lease) { + lifetime.publish(lease) { + connectionLease = lease + conn = c + _lastError.value = null + _state.value = ConnState.Connected(c.kind) + } + c.setOnClosed { reason -> + var reconnect = false + lifetime.whenCurrent(lease) { if (conn === c) { + conn = null + _state.value = ConnState.Failed("Disconnected" + (reason?.let { " ($it)" } ?: "") + ".") + reconnect = true + } } + // ForegroundRefresh/ConnectionUsers can close us under their own monitors. Never + // acquire those monitors while holding the lifetime publication monitor in reverse. + if (reconnect && isWatched) foreground.changed(stillCurrent = { lifetime.isCurrent(lease) && conn == null }) { + delay(1_500) + } + } + // A change the computer pushes is re-listed, and so announced, only while a screen shows this + // computer, like the reconnect above (the review of A73). The connection outlives that screen + // (until it drops or the background check closes it), and its pushes used to go on announcing + // live for a computer the user had left, which the Settings text does not promise. What such a + // push carried is not recorded as seen, so the background check announces it. + c.setOnChanged { if (lifetime.isCurrent(lease) && conn === c && isWatched) foreground.changed(stillCurrent = { lifetime.isCurrent(lease) && conn === c }) } + } + + private fun pinFor(host: PairedHost, lease: HostLifetime.Lease) = object : HostKeyPin { + private fun current(): PairedHost = graph.hosts.currentHost(host.id, host.hostKeyB64) { lifetime.isCurrent(lease) } + ?: throw kotlinx.coroutines.CancellationException("This computer connection changed.") + override fun pinned(): String? = current().sshHostKeyFingerprint + override fun pin(fingerprint: String) { + lifetime.publish(lease) { + graph.hosts.updateCurrent(host.id, host.hostKeyB64, { lifetime.isCurrent(lease) }) { it.copy(sshHostKeyFingerprint = fingerprint) } + } + } + // A handshake keeps the exact pairing it began with, never a same-id successor's anchors. + override fun anchors(): List = current().sshHostKeyAnchors + } + + /** The host key the SSH leg was refused in the connect that opened [connection] (review of A74-refresh). */ + private class SshRefusal(val connection: HostConnection, val hostKey: String) + + /** + * Set by [connectLocked] when the SSH leg was refused for its host key and the relay leg then + * connected: the key that server presented, which that relay connection's listings may confirm as + * the computer's own ([LanRefresh]). Tied to the connection, so a later one never inherits it. + */ + @Volatile private var sshRefusal: SshRefusal? = null + + /** + * Update this computer's LAN leg from what it said beside a RELAY listing (audit A74-refresh, + * [LanRefresh]): its current address and SSH host keys, and, when this connect's SSH leg was + * refused a key the computer now names as its own, a pin that no longer lets it in. + * [LanRefresh.afterListing] refuses a listing that came over SSH, which is the leg these facts + * check. Recomputed inside the store's update so it applies to the record as it is then (a late + * adoption may be writing the relay block at the same moment). Takes effect at the next SSH dial: + * [connectLocked] and [pinFor] read the record fresh. When the relay is in use because the SSH leg + * failed, the warning that says so gains a sentence about what changed. Never throws: a record that + * could not be saved is not a failed listing. + * + * Only for the PRIMARY connection, never for the relay held next to a live SSH one ([viaRelay]): + * there the LAN leg as recorded has just authenticated, which says more than a report does (a + * computer with two adapters may report the other one). + */ + private fun refreshLanLeg(c: HostConnection, listed: ProjectsSnapshot) { + val lease = connectionLease ?: return + runCatching { lifetime.whenCurrent(lease) { + if (conn !== c) return@whenCurrent + val before = graph.hosts.get(hostId) ?: return@whenCurrent + val refused = sshRefusal?.takeIf { it.connection === c }?.hostKey + val result = LanRefresh.afterListing(before, c.kind, listed, refused) ?: return@whenCurrent + graph.hosts.updateCurrent(hostId, before.hostKeyB64, { lifetime.isCurrent(lease) && conn === c }) { current -> LanRefresh.afterListing(current, c.kind, listed, refused)?.host ?: current } + val warning = _sshWarning.value + val note = LanRefresh.note(result) + if (warning != null && note != null && !warning.contains(note)) _sshWarning.value = "$warning $note" + } } + } + + /** One late adoption at a time: a connect and a listing may both ask for it. */ + private val adopting = AtomicBoolean(false) + + /** [adoptRelayIfAdvertised] over [ssh], in the background, unless one is already running. */ + private fun adoptInBackground(ssh: SshHostConnection) { + val lease = connectionLease?.takeIf { lifetime.isCurrent(it) && conn === ssh } ?: return + val host = graph.hosts.get(hostId) ?: return + if (!adopting.compareAndSet(false, true)) return + scope.launch { + try { + runCatching { adoptRelayIfAdvertised(ssh, host, lease) } + } finally { + adopting.set(false) + } + } + } + + /** + * LATE ADOPTION (relay-advertise.ts): a phone paired while remote access was off has no relay leg. + * While the standing host is up, the computer advertises its relay identity in + * `~/.nodeterm/relay.json`; read it over this TOFU-verified SSH connection and mint our own device + * token, so the phone can reach the computer from anywhere without re-pairing. + */ + private suspend fun adoptRelayIfAdvertised(ssh: SshHostConnection, host: PairedHost, lease: HostLifetime.Lease) { + // A computer added by its SSH address never gets a relay leg (audit A27): no pairing anchors + // the relay key such a file names, and its relay would not be one this phone was paired with. + if (host.manual) return + lifetime.requireCurrent(lease) + val tokenKey = SecureStore.relayTokenKey(host.id) + if (host.relay != null && graph.secure.getString(tokenKey) != null) return + val ad = ssh.readRelayAdvertisement() ?: return + lifetime.requireCurrent(lease) + val hostIdAd = J.str(ad["hostId"]) ?: return + val pub = J.str(ad["hostPublicKeyB64"]) ?: return + val endpoint = J.str(ad["relayEndpoint"])?.takeIf(PairingPayload::isAllowedRelayEndpoint) ?: return + val hostDeviceId = J.str(ad["hostDeviceId"]) ?: return + // The QR's host key is the identity we paired with: an advertisement naming another key is + // not this computer's relay identity. + if (host.hostKeyB64 != null && host.hostKeyB64 != pub) return + val minted = RelayApi(graph.hosts.apiBase).mintDevice( + deviceId = graph.identity.deviceId(), + hostDeviceId = hostDeviceId, + hostPublicKeyB64 = pub, + label = graph.hosts.deviceName, + priorDeviceToken = graph.secure.getString(tokenKey) + ) + // A mint may finish after Forget/re-pair/disconnect. Publish neither its token nor its + // relay to a retired connection/identity; the store fences both writes with retirement. + graph.hosts.adoptRelay(host.id, host.hostKeyB64, + RelayBlock(minted.hostId.ifEmpty { hostIdAd }, pub, endpoint), + currentConnection = { lifetime.isCurrent(lease) && conn === ssh }, + saveToken = { graph.secure.putString(tokenKey, minted.deviceToken) }) + } + + /** + * Re-list now. Connects first when needed; failures land in [state]/[lastError], never throw. + * [trigger] says who asked: a [Trigger.USER] refresh releases a held relay approval, a + * [Trigger.BACKGROUND] one never makes a first relay handshake (see [RelayApprovalGate]). + * + * A listing that arrives announces its new Inbox events, minus what [onScreen] shows (audit A73): + * the 8 s poll of the computer on screen, a change that computer pushed, and the background check + * all come through here. All computers watches every paired host while visible; an individual + * computer or terminal watches only its own host. Unwatched computers are not re-listed on a + * push over a connection retained for a quick return ([adopt]). + */ + suspend fun refreshNow(trigger: Trigger = Trigger.AUTO) { + val operation = lifetime.capture() + foreground.serial { + lifetime.requireCurrent(operation) + val listed = try { + val c = ensureConnected(trigger) + val lease = connectionLease ?: throw kotlinx.coroutines.CancellationException("This computer connection changed.") + val ssh = c as? SshHostConnection + val advertisedBefore = ssh?.relayAdvertised + c.listProjects().also { lifetime.publish(lease) { + if (conn !== c) throw kotlinx.coroutines.CancellationException("This computer connection changed.") + // A listing over SSH says whether the computer advertises its relay right now. A + // phone without a relay leg adopts it on the user's refresh, or as soon as remote + // access is turned on while this connection watches (audit A26: the reason on a + // disabled control promises that pickup). + if (ssh != null && LegRouting.adoptAfterListing(relayLeg(), advertisedBefore, ssh.relayAdvertised, userAsked = trigger == Trigger.USER)) { + adoptInBackground(ssh) + } + // A listing over the relay says where the computer is on the LAN now (A74-refresh). + refreshLanLeg(c, it) + _snapshot.value = it + _lastError.value = null + } } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + lifetime.whenCurrent(operation) { + _snapshot.value = ListingFailure.snapshot(_snapshot.value, e) + // Never null: an exception without a message must still say that the listing failed. + // "Nothing found" offers the relay only when this phone has one to offer (review of A27b). + _lastError.value = (e as? NothingFoundException)?.said(relayLeg()) ?: e.message ?: e.javaClass.simpleName + // A HostException is an ANSWER (the host refused, or the connection already reported its + // own drop). Anything else is an unexpected transport failure: drop the connection so the + // next refresh dials a fresh one instead of reusing a dead socket. + if (e !is HostException) disconnect() + } + return@serial + } + // Outside the try: a notification the system refused is not a failed listing, and must not + // drop a working connection. No network here — the listing just arrived. + runCatching { lifetime.whenCurrent(operation) { graph.announce(hostId, listed, onScreen.now(), quiet = reachableQuietly()) } } + } + } + + /** + * A terminal of this computer just attached, so the pane of [nodeId] is in front of the user (the + * A73 review). The listing that arrived while it was connecting left that session's events + * waiting ([OnScreen.opening]), and the next listing is up to 8 s away: record what the pane + * shows of the latest listing as seen now, so a user who looks and leaves before then is not told + * about it afterwards. Records only, announces nothing, and costs no network call. + */ + fun notePaneShown(nodeId: String) { + val events = _snapshot.value.status?.inbox?.events ?: return + val lease = connectionLease ?: return + runCatching { lifetime.whenCurrent(lease) { graph.hosts.claimLive(hostId, events, OnScreen(nodes = setOf(nodeId)), notify = false) } } + } + + /** + * A refresh the USER asked for (Refresh, Try again), by default. The All computers screen re-lists + * every computer as the app's own foreground refresh instead ([Trigger.AUTO], audit A55): it may + * show a first approval code, but it does not lift a computer's held refusal; that computer's own + * Try again does. + */ + fun refresh(trigger: Trigger = Trigger.USER) { + scope.launch { refreshNow(trigger) } + } + + /** + * While a screen shows this computer, re-list every 8 s (the iOS foreground cadence); each listing + * announces what is new and not on screen ([refreshNow]). + */ + fun startWatching(initialTrigger: Trigger = Trigger.USER) { + lifetime.capture() + foreground.start(initialTrigger) + } + + fun stopWatching(closeWhenUnused: Boolean = false) { + foreground.stop(closeWhenUnused) + } + + /** + * Drop the connection. Callers include click handlers on the MAIN thread (Forget, a route change, + * re-pairing), so the close itself runs on [Dispatchers.IO]: a socket write there throws + * `NetworkOnMainThreadException` and would leak the socket (audit A01). + */ + fun disconnect() = lifetime.disconnect { disconnectOwned() } + + /** Change the browse/adoption profile under the same fence that retires old transport work. */ + fun changeSshProfile(path: String?) { + dev.nodeterm.protocol.ssh.SshProfilePath.requireValid(path) + lifetime.disconnect { + if (!lifetime.active) return@disconnect + graph.hosts.update(hostId) { it.copy(sshProfilePath = path) } + disconnectOwned() + _snapshot.value = ProjectsSnapshot.EMPTY + _lastError.value = null + _sshWarning.value = null + } + } + + /** Removed from ConnectionManager permanently; a new pairing gets a new HostSession object. */ + fun retire() = lifetime.retire { disconnectOwned() } + + private fun disconnectOwned() { + val c = conn + conn = null + connectionLease = null + val side = sideRelay + sideRelay = null + if (c != null || side != null) scope.launch(Dispatchers.IO) { + runCatching { c?.close() } + runCatching { side?.close() } + } + _state.value = ConnState.Idle + } + + companion object { + const val POLL_MS = 8_000L + } +} + +class ConnectionManager(private val graph: AppGraph) { + private val sessions = HostSessionRegistry( + create = { hostId: String -> HostSession(hostId, graph) }, + retire = { hostId, session -> + session?.retire() + graph.relayGate.forget(hostId) + graph.terminalDrafts.retireOwner(hostId) + }, + ) + + @Synchronized + fun session(hostId: String): HostSession = sessions.session(hostId) + + @Synchronized + fun forget(hostId: String) = sessions.forget(hostId) + + /** Local store/secret publication only; connection closes are dispatched by retire(). */ + @Synchronized + fun retireAndPublish(hostIds: Collection, publish: () -> T): T = + sessions.retireAndPublish(hostIds, publish) + + @Synchronized + fun all(): List = sessions.all() +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/DictationPreferences.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/DictationPreferences.kt new file mode 100644 index 0000000000..36758d22f2 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/DictationPreferences.kt @@ -0,0 +1,18 @@ +package dev.nodeterm.android.data + +import android.content.Context +import dev.nodeterm.protocol.model.DictationLanguage + +/** Phone-wide speech preference, separate from computer identities and terminal drafts. */ +class DictationPreferences(context: Context) { + private val prefs = context.applicationContext.getSharedPreferences("dictation", Context.MODE_PRIVATE) + var languageTag: String? + get() = DictationLanguage.canonical(prefs.getString("languageTag", null)) + set(value) { + require(value == null || DictationLanguage.canonical(value) != null) { "Invalid dictation language" } + val edit = prefs.edit() + if (value == null) edit.remove("languageTag") + else edit.putString("languageTag", DictationLanguage.canonical(value)) + edit.apply() + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt new file mode 100644 index 0000000000..c46819db66 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt @@ -0,0 +1,298 @@ +package dev.nodeterm.android.data + +import android.content.Context +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.OnScreen +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.PairedHostReplacement +import dev.nodeterm.protocol.model.SeenLog +import dev.nodeterm.protocol.host.ManagedSessionCreation +import dev.nodeterm.protocol.secure.PlainStorage +import dev.nodeterm.protocol.ssh.ManualHost +import dev.nodeterm.protocol.pairing.RelayBlock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject + +/** How to reach a computer. Auto = direct SSH on the LAN first, the relay when that fails. */ +enum class RoutePreference { AUTO, SSH_ONLY, RELAY_ONLY } + +/** + * The paired computers (public facts only — see [PairedHost]) plus phone-level preferences. Secrets + * are in [SecureStore]. + */ +class HostStore(context: Context) { + private val prefs = context.getSharedPreferences("nodeterm.hosts", Context.MODE_PRIVATE) + private val _hosts = MutableStateFlow(load()) + val hosts: StateFlow> = _hosts.asStateFlow() + + private fun load(): List { + val raw = prefs.getString("hosts", null) ?: return emptyList() + return try { + (Json.parseToJsonElement(raw) as? JsonArray)?.mapNotNull { (it as? JsonObject)?.let(PairedHost::fromJson) } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + } + + private fun save(list: List, retiredKeys: List = emptyList()) { + val edit = prefs.edit() + for (key in retiredKeys) edit.remove(key) + // Publish record retirement and its local preferences together, before notifying readers. + edit.putString("hosts", JsonArray(list.map { it.toJson() }).toString()).apply() + _hosts.value = list + } + + fun get(id: String): PairedHost? = _hosts.value.firstOrNull { it.id == id } + + /** + * Re-pairing the same computer (same host + user + name) replaces the old record and retires + * its route, approval and creation preferences. An update of the same id retains those values. + * What the phone saw of + * a replaced record's computer carries over to [host] ([SeenLog.moveHost]): it is the same + * computer, whose event ids continue. + */ + @Synchronized + fun upsert(host: PairedHost) { + val replacement = PairedHostReplacement.plan(_hosts.value, host) + val replaced = replacement.replaced + save(replacement.hosts, replacement.retiredPreferenceKeys) + for (old in replaced) seenLog.moveHost(old.id, host.id) + } + + /** Complete re-pairing after its old connections were forgotten. Only local secret writes + * occur under this monitor, so an already-completed old mint cannot replace the new token. */ + @Synchronized + fun publishPairing(host: PairedHost, previous: PairedHost?, saveToken: () -> Unit, + removeToken: (String) -> Unit) { + for (old in PairedHostReplacement.retired(_hosts.value, host, previous)) { + if (old.id != host.id) remove(old.id, successor = host.id) { removeToken(old.id) } + } + saveToken() + upsert(host) + } + + /** + * Keep a computer added by its SSH address (audit A27). Unlike [upsert] it replaces nothing: when + * a computer is already listed under that address (paired or added), that one is returned and + * nothing is saved. Null when it was added. + */ + @Synchronized + fun addManual(host: PairedHost): PairedHost? { + require(host.manual) { "only a computer added by its SSH address" } + ManualHost.existing(_hosts.value, ManualHost.Address(host.host, host.port, host.user, host.name))?.let { return it } + save(_hosts.value + host) + // [route] answers SSH for it from the record; this is for a build that predates `manual`, + // which reads only the stored route, so it too never dials a relay for it. + prefs.edit().putString("route.${host.id}", RoutePreference.SSH_ONLY.name).apply() + return null + } + + @Synchronized + fun update(id: String, change: (PairedHost) -> PairedHost) { + save(_hosts.value.map { if (it.id == id) change(it) else it }) + } + + /** Exact session guard for asynchronous pin/LAN facts. Its predicate must be nonlocking. */ + @Synchronized + fun currentHost(id: String, expectedHostKey: String?, current: () -> Boolean): PairedHost? { + val host = get(id) ?: return null + return host.takeIf { current() && it.hostKeyB64 == expectedHostKey } + } + + @Synchronized + fun updateCurrent(id: String, expectedHostKey: String?, current: () -> Boolean, + change: (PairedHost) -> PairedHost): Boolean { + val host = currentHost(id, expectedHostKey, current) ?: return false + save(_hosts.value.map { if (it.id == id) change(host) else it }) + return true + } + + /** Publish a completed mint only while its exact connection and paired identity still exist. + * The token callback has no network await; retirement cannot interleave with either write. */ + @Synchronized + fun adoptRelay(hostId: String, expectedHostKey: String?, relay: RelayBlock, + currentConnection: () -> Boolean, saveToken: () -> Unit): Boolean { + val host = get(hostId) ?: return false + if (!currentConnection() || host.manual || host.hostKeyB64 != expectedHostKey || + expectedHostKey != null && relay.hostPublicKeyB64 != expectedHostKey) return false + saveToken() + save(_hosts.value.map { if (it.id == hostId) it.copy(relay = relay) else it }) + return true + } + + /** + * Forget the computer [id]. Its notification seen log goes with it ([SeenLog.forgetHost]), unless + * the same computer was just paired again as [successor]: then that record keeps what the phone + * saw of it ([SeenLog.moveHost]), so pairing again does not announce it all a second time. + */ + @Synchronized + fun remove(id: String, successor: String? = null, removeToken: () -> Unit = {}) { + // Keep the record if secret retirement fails, as the Forget/re-pair callers did before. + removeToken() + save(_hosts.value.filterNot { it.id == id }) + prefs.edit().remove("route.$id").remove("relayApproved.$id").remove("managedCreation.$id").apply() + when (successor) { + null -> seenLog.forgetHost(id) + id -> Unit + else -> seenLog.moveHost(id, successor) + } + } + + /** + * This computer serves this phone over the relay without an approval dialog: a relay connect has + * succeeded (its standing host pinned our box key), or pairing answered `relayApproved` — it + * pinned the key, or recorded it for the standing host to pin on this phone's first relay + * handshake, which may then be the background worker's (audit A07-late). Gates the background + * worker's relay leg (dev.nodeterm.protocol.host.RelayApprovalGate, audit A05). + */ + fun relayApproved(id: String): Boolean = prefs.getBoolean("relayApproved.$id", false) + + @Synchronized + fun setRelayApproved(id: String, approved: Boolean) { + if (get(id) == null) return + prefs.edit().putBoolean("relayApproved.$id", approved).apply() + } + + /** How to reach [id]. A computer added by its SSH address has SSH only (audit A27), whatever is stored. */ + fun route(id: String): RoutePreference { + if (get(id)?.manual == true) return RoutePreference.SSH_ONLY + return runCatching { RoutePreference.valueOf(prefs.getString("route.$id", null) ?: "AUTO") }.getOrDefault(RoutePreference.AUTO) + } + + @Synchronized + fun setRoute(id: String, route: RoutePreference) { + val host = get(id) ?: return + if (host.manual) return + prefs.edit().putString("route.$id", route.name).apply() + } + + /** + * Where the phone's relay deviceId is kept (under `deviceId`, as before). It is read and minted + * only through dev.nodeterm.protocol.secure.PhoneIdentity (`AppGraph.identity`), which keeps it + * coupled to the box key it belongs to (audit A51). Durable writes use commit() and throw when it + * fails: the deviceId must be gone from disk before a new box key is written to the other + * preferences file, and a failed removal must stop that key from being written. + */ + val identityStorage: PlainStorage = object : PlainStorage { + override fun get(name: String): String? = prefs.getString(name, null) + override fun put(name: String, value: String, durable: Boolean) = + write(prefs.edit().putString(name, value), durable) + override fun remove(name: String, durable: Boolean) = + write(prefs.edit().remove(name), durable) + + private fun write(edit: android.content.SharedPreferences.Editor, durable: Boolean) { + if (!durable) return edit.apply() + if (!edit.commit()) throw java.io.IOException("Couldn't save the phone's relay id.") + } + } + + /** Public creation identities only, scoped to this exact host record. Commit before dispatch. */ + fun managedCreationStorage(hostId: String, current: () -> Boolean = CURRENT_RECORD): ManagedSessionCreation.Storage = object : ManagedSessionCreation.Storage { + override fun read(): String? = synchronized(this@HostStore) { + if (!current() || get(hostId) == null) null else prefs.getString("managedCreation.$hostId", null) + } + override fun write(encoded: String) { + synchronized(this@HostStore) { + if (!current() || get(hostId) == null) throw java.io.IOException("This computer was forgotten. No creation checkpoint will be saved.") + if (!prefs.edit().putString("managedCreation.$hostId", encoded).commit()) { + throw java.io.IOException("Couldn't save the pending session creation. Nothing new will be sent.") + } + } + } + } + + var deviceName: String + get() = prefs.getString("deviceName", null) ?: (android.os.Build.MODEL ?: "Android phone") + set(value) { + prefs.edit().putString("deviceName", value.trim().ifEmpty { android.os.Build.MODEL ?: "Android phone" }).apply() + } + + var apiBase: String + get() = prefs.getString("apiBase", null) ?: dev.nodeterm.protocol.relay.RelayApi.DEFAULT_API_BASE + set(value) { + prefs.edit().putString("apiBase", value.trim().trimEnd('/')).apply() + } + + /** + * Forget the stored relay address, so [apiBase] answers the built-in default, a later build's + * included (dev.nodeterm.protocol.relay.ApiBaseSetting.OnLeave.UseDefault). Removing a key + * that is not there changes nothing. + */ + fun useDefaultApiBase() { + prefs.edit().remove("apiBase").apply() + } + + var notificationsEnabled: Boolean + get() = prefs.getBoolean("notify", true) + set(value) { + prefs.edit().putBoolean("notify", value).apply() + } + + /** + * "Show details in notifications": put the event's own text (the command, file or question and + * the agent's last message) in its notification. OFF by default, because Android shows a + * notification's full content on a secure lock screen unless the user hides sensitive content + * (audit A52; the words are dev.nodeterm.protocol.model.InboxNotificationText's). + */ + var notificationDetails: Boolean + get() = prefs.getBoolean("notifyDetails", false) + set(value) { + prefs.edit().putBoolean("notifyDetails", value).apply() + } + + var fontSize: Int + get() = prefs.getInt("fontSize", 13) + set(value) { + prefs.edit().putInt("fontSize", value.coerceIn(8, 24)).apply() + } + + /** + * Inbox events this phone has announced, read or had on screen (phone-local, like iOS), per + * computer, trimmed by age and locked across each update — see [SeenLog] (audit A48 and its + * per-computer follow-up). Two older formats migrate on first use, and the same edit removes + * them: the phone-wide id → time map under `seenEvents.v2`, and the pre-A48 bare id set under + * `seenEvents`. + */ + private val seenLog: SeenLog = SeenLog(object : SeenLog.Storage { + override fun read(): String? = prefs.getString(SEEN_LOG_KEY, null) + override fun readV2(): String? = prefs.getString(V2_SEEN_KEY, null) + override fun readLegacy(): Set? = prefs.getStringSet(LEGACY_SEEN_KEY, null) + override fun write(encoded: String) { + // apply() publishes to the in-memory map before it returns; SeenLog holds the lock. + prefs.edit().putString(SEEN_LOG_KEY, encoded).remove(V2_SEEN_KEY).remove(LEGACY_SEEN_KEY).apply() + } + }) + + /** + * The phone has seen [events] of the computer [hostId]: the user read them here. Nothing is + * recorded for a computer no longer paired, so a screen still open on a computer just forgotten + * cannot bring back the entries [remove] dropped. + */ + @Synchronized + fun markSeen(hostId: String, events: Collection) { + if (get(hostId) == null) return + seenLog.markSeen(hostId, events) + } + + /** + * The events of a fresh listing of the computer [hostId] to notify about now, recorded as + * announced in the same locked step; what [onScreen] shows is recorded as seen instead (audit + * A73) — see [SeenLog.claimLive]. Nothing, and nothing recorded, for a computer no longer paired. + */ + @Synchronized + fun claimLive(hostId: String, events: List, onScreen: OnScreen, notify: Boolean): List { + if (get(hostId) == null) return emptyList() + return seenLog.claimLive(hostId, events, onScreen, notify) + } + + private companion object { + val CURRENT_RECORD: () -> Boolean = { true } + const val SEEN_LOG_KEY = "seenEvents.v3" + const val V2_SEEN_KEY = "seenEvents.v2" + const val LEGACY_SEEN_KEY = "seenEvents" + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt b/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt new file mode 100644 index 0000000000..600da2bce2 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt @@ -0,0 +1,146 @@ +package dev.nodeterm.android.data + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import dev.nodeterm.protocol.secure.PhoneIdentity +import dev.nodeterm.protocol.secure.PlainStorage +import dev.nodeterm.protocol.secure.SecretStorage +import dev.nodeterm.protocol.secure.SecretStoreCore +import dev.nodeterm.protocol.secure.SecretUnavailableException +import dev.nodeterm.protocol.secure.Sealer +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlinx.coroutines.flow.StateFlow + +/** + * Secrets at rest: AES-256-GCM under a key that lives in the Android Keystore (it never leaves the + * secure hardware / keystore daemon), ciphertext in app-private SharedPreferences. What it holds is + * exactly what the iOS app keeps in its Keychain: + * + * - the phone's persistent NaCl box secret key — the identity the desktop PINS on first approval, + * so it must survive restarts (a fresh key per launch would re-prompt every time and break pinning); + * - the phone's Ed25519 SSH seed (its public half sits in the computer's authorized_keys); + * - relay device tokens, one per paired computer (bearer credentials for `/v1/relay/join`). + * + * Nothing here leaves the phone in a backup or a device-to-device transfer: `allowBackup="false"` + * covers cloud backup, and the data extraction rules (res/xml/data_extraction_rules.xml) cover the + * Android 12+ transfer, which ignores `allowBackup` (audit A51). A copied blob could not be opened on + * another device anyway; [PhoneIdentity] makes sure the relay deviceId never outlives the box key. + */ +class SecureStore(context: Context) { + private val prefs = context.getSharedPreferences("nodeterm.secure", Context.MODE_PRIVATE) + + /** + * The Keystore key. Generated ONLY when the alias is absent: a keystore that briefly answers + * null for a key it holds must not make us generate a new one under the same alias, which would + * make every stored secret unreadable (audit A24, verifier note (b)). + */ + private val key: SecretKey by lazy { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + if (ks.containsAlias(ALIAS)) { + ks.getKey(ALIAS, null) as? SecretKey + ?: throw java.security.KeyStoreException("The Keystore holds $ALIAS but did not return it.") + } else { + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + .apply { + init( + KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + } + .generateKey() + } + } + + /** + * The rules (what counts as "gone", what is only "unreadable right now") live in the tested + * [SecretStoreCore]; this class is only the Keystore cipher and the preferences file. + */ + private val core = SecretStoreCore( + storage = object : SecretStorage { + override fun get(name: String): String? = prefs.getString(name, null) + override fun put(name: String, value: String, durable: Boolean) { + val edit = prefs.edit().putString(name, value) + if (durable) edit.commit() else edit.apply() + } + override fun remove(name: String) { + prefs.edit().remove(name).apply() + } + }, + sealer = object : Sealer { + override fun seal(plain: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key) + return cipher.iv + cipher.doFinal(plain) + } + override fun open(sealed: ByteArray): ByteArray { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed, 0, IV_BYTES)) + return cipher.doFinal(sealed, IV_BYTES, sealed.size - IV_BYTES) + } + } + ) + + fun putBytes(name: String, value: ByteArray) = core.putBytes(name, value) + + /** + * The value, or null when absent or provably lost. A value that cannot be read RIGHT NOW (a + * keystore hiccup) also reads as null here — callers of this form only read (relay tokens) and + * overwrite nothing; the identity secrets go through [getOrCreate32], which never overwrites on + * such an error. + */ + fun getBytes(name: String): ByteArray? = try { + core.getBytes(name) + } catch (_: SecretUnavailableException) { + null + } + + fun putString(name: String, value: String) = putBytes(name, value.toByteArray(Charsets.UTF_8)) + fun getString(name: String): String? = getBytes(name)?.toString(Charsets.UTF_8) + + fun remove(name: String) = core.remove(name) + + /** + * A value is stored under [name]: answered from the preferences alone — no Keystore decrypt and + * no waiting on this store's lock — so a screen may ask it while composing (audit A47). What it + * cannot tell is whether the value still opens; anything that uses the value reads it instead. + */ + fun contains(name: String): Boolean = core.contains(name) + + /** This phone holds a relay device token for the computer paired as [hostId] (see [contains]). */ + fun hasRelayToken(hostId: String): Boolean = contains(relayTokenKey(hostId)) + + /** Changes whenever a secret is stored or removed: the key to re-ask [contains] on. */ + val revision: StateFlow get() = core.revision + + /** + * Get-or-create 32 random bytes under [name] (the box secret, the SSH seed). Replaces a stored + * value only when it is provably gone; throws [SecretUnavailableException] when it merely cannot + * be read right now, so the phone's identity is never overwritten by a transient error. + */ + fun getOrCreate32(name: String): ByteArray = core.getOrCreate32(name) + + /** + * The phone's relay identity (box secret + deviceId), with the deviceId kept in [plain]. The box + * secret is read and created ONLY through it, never through [getOrCreate32]: that is what drops + * the deviceId whenever the key it belongs to has to be created again (audit A51). + */ + fun phoneIdentity(plain: PlainStorage): PhoneIdentity = PhoneIdentity(core, plain) + + companion object { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val ALIAS = "nodeterm.secure.v1" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_BYTES = 12 + + const val SSH_SEED = "ssh.seed" + fun relayTokenKey(hostId: String) = "relay.token.$hostId" + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxActions.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxActions.kt new file mode 100644 index 0000000000..b266720919 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxActions.kt @@ -0,0 +1,102 @@ +package dev.nodeterm.android.notify + +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.InboxNotificationActions +import dev.nodeterm.protocol.host.InboxNotificationActions.Outcome +import dev.nodeterm.protocol.host.InboxNotificationActions.Request +import dev.nodeterm.protocol.host.RelayApprovalGate +import kotlinx.coroutines.CancellationException + +/** + * An answer given from an Inbox notification (audit A25): Approve, Deny or a question's option, as + * [InboxNotificationActions.plan] put them on the notification. + * + * The tap arrives here, in a receiver the system starts for this app only (it is not exported, and + * the action's PendingIntent is explicit and immutable). The receiver does nothing slow: it takes the + * actions off the notification ("Approving…"), so a second tap cannot send a second answer, and hands + * the answer to [InboxActionWorker] as expedited work, which may connect for as long as a dial takes. + * One answer per event is in flight at a time (unique work, KEEP). + * + * Android 12 and later ask for the unlock before they send an answering action + * (`setAuthenticationRequired`); Android 11 and lower send it from a locked screen, so the receiver + * refuses a locked phone itself and the notification says to unlock. + */ +class InboxActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val request = Request.decode(intent.getStringExtra(InboxNotifier.EXTRA_REQUEST)) ?: return + if (!request.action.answers) return + if (Build.VERSION.SDK_INT < 31 && context.getSystemService(KeyguardManager::class.java)?.isDeviceLocked == true) { + InboxNotifier.settle(context, request, Outcome.LOCKED) + return + } + InboxNotifier.sending(context, request) + val work = OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setInputData(Data.Builder().putString(InboxActionWorker.KEY_REQUEST, request.encode()).build()) + .build() + WorkManager.getInstance(context).enqueueUniqueWork(request.workName, ExistingWorkPolicy.KEEP, work) + } +} + +/** + * Sends one answer from a notification and says on the notification how it went. + * + * It connects to that computer the way the background check does, never making a FIRST relay + * handshake: the user tapped, but is not looking at the app, so nobody would compare the approval + * code the desktop shows (RelayApprovalGate, audit A05). A connection already open is reused; else + * the SSH leg or a relay that has already approved this phone; else the notification says to open + * the app, which connects as the user. The answer is [dev.nodeterm.protocol.host.QuickActions]', + * with its re-checks, through [InboxNotificationActions.answerOnce]. It is never retried: one that may + * have arrived is reported as unconfirmed, and a second `1` would land in whatever the pane shows by + * then. That covers the runs WorkManager starts again on its own after an interrupted one + * ([runAttemptCount] above 0): they send nothing. The connection is shared with the other + * background jobs and the screens, and closed afterwards only when none of them uses it + * ([HostSession.inBackground]). + */ +class InboxActionWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val request = Request.decode(inputData.getString(KEY_REQUEST)) ?: return Result.success() + val graph = NodetermApp.graph(applicationContext) + val outcome = try { + val session = if (graph.hosts.get(request.hostId) == null) null else graph.connections.session(request.hostId) + InboxNotificationActions.answerOnce(request.action, request.event, runAttemptCount, session?.let(::quietRoute)) + } catch (e: CancellationException) { + // Stopped by the system mid-way: whether the answer arrived is not known. + InboxNotifier.settle(applicationContext, request, Outcome.UNCONFIRMED) + throw e + } + InboxNotifier.settle(applicationContext, request, outcome) + return Result.success() + } + + /** The computer as the answer reaches it: every dial the background one, which never makes a first relay handshake. */ + private fun quietRoute(session: HostSession) = object : InboxNotificationActions.QuietRoute { + override val connected: Boolean get() = session.connection != null + override fun reachableQuietly(): Boolean = session.reachableQuietly() + override suspend fun hold(block: suspend () -> Outcome): Outcome = session.inBackground(block) + override suspend fun connect(): HostConnection = session.ensureConnected(RelayApprovalGate.Trigger.BACKGROUND) + override suspend fun viaRelay(): HostConnection = session.viaRelay(RelayApprovalGate.Trigger.BACKGROUND) + } + + /** Android 11 and lower run expedited work as a foreground service, which needs a notification. */ + override suspend fun getForegroundInfo(): ForegroundInfo = InboxNotifier.sendingForeground(applicationContext) + + companion object { + const val KEY_REQUEST = "request" + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt new file mode 100644 index 0000000000..7b4ea274b7 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt @@ -0,0 +1,314 @@ +package dev.nodeterm.android.notify + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dev.nodeterm.android.MainActivity +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.R +import dev.nodeterm.android.ui.displayTitle +import dev.nodeterm.protocol.host.InboxNotificationActions +import dev.nodeterm.protocol.host.InboxNotificationActions.Outcome +import dev.nodeterm.protocol.host.InboxNotificationActions.Request +import dev.nodeterm.protocol.host.RelayApprovalGate +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.InboxNotificationText +import dev.nodeterm.protocol.model.OnScreen +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit + +/** + * Notifications for "an agent needs you" / "an agent finished". + * + * The iOS app gets these as APNs pushes fanned out by the nodeterm backend (src/core/push-notify.ts). + * That backend has no Android (FCM) leg, so this app cannot be woken the same way. What it does + * instead, honestly: checked about every 15 minutes in the background, and live while the computer + * or All computers is on screen. Both are the same [announce], run on every fresh listing of a computer + * (HostSession.refreshNow): the periodic WorkManager check (Android's floor is 15 minutes) lists + * each paired computer. A computer's own screen watches that host; All computers watches every + * paired host while STARTED. Both re-list every 8 s and on that host's change (audit A73). Unwatched + * computers do not poll or re-list pushes over a connection retained for a quick return. What the + * user is looking at ([OnScreen]; on the merged Inbox, every computer's Inbox) is recorded as seen + * instead of announced. A real push leg is backend work. + * + * A notification's tap opens that session's terminal, and an approval or a question carries the + * answers the Inbox card offers (audit A25): which ones is [InboxNotificationActions.plan], and an + * answer runs in [InboxActionReceiver] / [InboxActionWorker], which update the notification here + * ([sending], [settle]). + */ +object InboxNotifier { + private const val CH_ATTENTION = "attention" + private const val CH_DONE = "done" + private const val CH_SENDING = "sending" + private const val WORK = "nodeterm.inbox" + + /** The [Request] an answering action carries to [InboxActionReceiver]. */ + const val EXTRA_REQUEST = "dev.nodeterm.android.notify.REQUEST" + + /** The notification an answer runs under on Android 11 and lower, where expedited work is a foreground service. */ + private const val SENDING_ID = 0x4e54_0025 + + /** This phone will actually SHOW our notifications: the app-level switch in system settings, + * which on Android 13+ also reflects the runtime POST_NOTIFICATIONS permission (audit A21). */ + fun canPost(context: Context): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() + + fun createChannels(context: Context) { + if (Build.VERSION.SDK_INT < 26) return + val nm = context.getSystemService(NotificationManager::class.java) ?: return + nm.createNotificationChannel( + NotificationChannel(CH_ATTENTION, context.getString(R.string.channel_attention), NotificationManager.IMPORTANCE_HIGH) + .apply { description = context.getString(R.string.channel_attention_desc) } + ) + nm.createNotificationChannel( + NotificationChannel(CH_DONE, context.getString(R.string.channel_done), NotificationManager.IMPORTANCE_DEFAULT) + .apply { description = context.getString(R.string.channel_done_desc) } + ) + nm.createNotificationChannel( + NotificationChannel(CH_SENDING, context.getString(R.string.channel_sending), NotificationManager.IMPORTANCE_LOW) + .apply { description = context.getString(R.string.channel_sending_desc) } + ) + } + + fun schedule(context: Context, enabled: Boolean) { + val wm = WorkManager.getInstance(context) + if (!enabled) { + wm.cancelUniqueWork(WORK) + return + } + val req = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build() + wm.enqueueUniquePeriodicWork(WORK, ExistingPeriodicWorkPolicy.KEEP, req) + } + + /** + * Announce the events of a fresh listing of [host] not announced before, except what [onScreen] + * says the user is looking at: those are recorded as seen (audit A73). Returns how many were + * posted. + */ + fun announce(context: Context, host: PairedHost, snapshot: ProjectsSnapshot, onScreen: OnScreen, quiet: Boolean): Int { + val graph = NodetermApp.graph(context) + // The switch, and whether the system will show it (A21): with either off nothing is claimed, + // but what is on screen is still recorded as seen, so turning them on later does not + // announce what the user already looked at. + val permitted = Build.VERSION.SDK_INT < 33 || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + val notify = graph.hosts.notificationsEnabled && canPost(context) && permitted + // Decided and recorded in one locked step (SeenLog, audits A48/A73): unresolved, younger than + // the announce window, not on screen, and never announced, read or shown on this phone FOR + // THIS COMPUTER — two computers can mint the same event id, so the log is kept per computer — + // nor under another pairing for the same node: the SSH host a paired desktop drives lists + // that desktop's events too (A27), and one event raises one notification. + val fresh = graph.hosts.claimLive(host.id, snapshot.status?.inbox?.events.orEmpty(), onScreen, notify) + if (fresh.isEmpty()) return 0 + val nm = NotificationManagerCompat.from(context) + val showDetails = graph.hosts.notificationDetails + for (ev in fresh.takeLast(5)) { + nm.notify(InboxNotificationActions.notificationId(host.id, ev.id), build(context, host, snapshot, ev, showDetails, quiet)) + } + return fresh.size + } + + /** + * The event's own text (the command, file or question, the agent's last message) is left out + * unless the user opted in: Android shows a notification's full content on a secure lock screen + * under its default setting, and a public version changes that only for users who hide sensitive + * content (audit A52). The words are [InboxNotificationText]'s; the public version is always set. + * + * The tap opens that session's terminal, and the actions are the Inbox card's answers as far as a + * notification can carry them (audit A25, [InboxNotificationActions.plan]). + */ + private fun build( + context: Context, + host: PairedHost, + snapshot: ProjectsSnapshot, + ev: InboxEvent, + showDetails: Boolean, + quiet: Boolean + ): android.app.Notification { + val node = snapshot.findNode(ev.nodeId)?.second + val session = snapshot.statusOf(ev.nodeId)?.name?.takeIf { it.isNotBlank() } ?: node?.title + val words = InboxNotificationText.of(ev, session, host.name, showDetails) + val channel = if (ev.kind == InboxKind.DONE) CH_DONE else CH_ATTENTION + val id = InboxNotificationActions.notificationId(host.id, ev.id) + // The terminal's title as the Inbox card names the session, so the screen the tap opens reads the same. + val title = node?.let { displayTitle(it, snapshot) } + ?: snapshot.statusOf(ev.nodeId)?.name?.takeIf { it.isNotBlank() } + ?: InboxNotificationText.FALLBACK_SESSION + val open = openSession(context, id, host.id, ev.nodeId, title) + val publicVersion = NotificationCompat.Builder(context, channel) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(words.publicTitle) + .setContentText(words.publicText) + .setWhen(ev.ts) + .build() + val builder = NotificationCompat.Builder(context, channel) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(words.title) + .setContentText(words.text) + .setSubText(host.name) + .setWhen(ev.ts) + .setAutoCancel(true) + .setContentIntent(open) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(publicVersion) + words.bigText?.let { builder.setStyle(NotificationCompat.BigTextStyle().bigText(it)) } + // An answer from here never makes a first relay handshake (nobody is at the app to compare the + // desktop's code): a computer reachable only that way gets Open instead of answers. + // The listing's exact HostSession supplies this fact; a manager lookup here would invert + // ConnectionManager.forget's lock order while lifetime-gated notifications publish. + for (action in InboxNotificationActions.plan(ev, showDetails, quiet)) { + val intent = if (action.answers) answerIntent(context, Request(host.id, host.name, id, words.title, title, action, ev)) + else open + // An answer needs an unlocked phone: Android 12 and later ask for the unlock before they + // send the action; below that the receiver refuses a locked phone itself. + builder.addAction( + NotificationCompat.Action.Builder(R.drawable.ic_launcher_foreground, action.label, intent) + .setAuthenticationRequired(action.answers) + .build() + ) + } + return builder.build() + } + + /** + * What a notification's tap (and its Open action) opens: that session's terminal, with its + * computer's Inbox one Back away (audit A25, MainActivity.openTap). One PendingIntent per + * notification ([notificationId] is its request code): PendingIntents are told apart by their + * Intent WITHOUT its extras, so with one request code per computer FLAG_UPDATE_CURRENT would hand + * every notification of that computer the session of the last one posted. + */ + private fun openSession(context: Context, notificationId: Int, hostId: String, nodeId: String, title: String): PendingIntent = + PendingIntent.getActivity( + context, + notificationId, + Intent(context, MainActivity::class.java) + .putExtra(MainActivity.EXTRA_HOST_ID, hostId) + .putExtra(MainActivity.EXTRA_NODE_ID, nodeId) + .putExtra(MainActivity.EXTRA_NODE_TITLE, title) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + /** + * An answering action: an explicit broadcast to the app's own unexported [InboxActionReceiver], + * immutable, carrying the [Request]. Its data names the notification and the action, so each + * action of each notification is a PendingIntent of its own. + */ + private fun answerIntent(context: Context, request: Request): PendingIntent = + PendingIntent.getBroadcast( + context, + request.notificationId, + Intent(context, InboxActionReceiver::class.java) + .setData(Uri.fromParts("nodeterm-answer", "${request.notificationId}/${request.action.key}", null)) + .putExtra(EXTRA_REQUEST, request.encode()), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + /** The answer is on its way. The actions go with this update, so a second tap cannot send another. */ + fun sending(context: Context, request: Request) { + post( + context, request.notificationId, + reposted(context, request) + .setContentText(InboxNotificationActions.sendingText(request.action)) + .setProgress(0, 0, true) + .build() + ) + } + + /** The answer settled as [outcome]: say so, keeping the tap that opens the session. */ + fun settle(context: Context, request: Request, outcome: Outcome) { + val settled = InboxNotificationActions.settled(outcome, request.action, request.computerName) + val builder = reposted(context, request) + .setContentText(settled.text) + .setStyle(NotificationCompat.BigTextStyle().bigText(settled.text)) + settled.dismissAfterMs?.let { builder.setTimeoutAfter(it) } + post(context, request.notificationId, builder.build()) + } + + /** + * The notification an answer's work shows while it runs as a foreground service: Android 11 and + * lower run expedited work that way. Android 12 and later never ask for it. + */ + fun sendingForeground(context: Context): ForegroundInfo = + ForegroundInfo( + SENDING_ID, + NotificationCompat.Builder(context, CH_SENDING) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Sending your answer…") + .setSilent(true) + .build() + ) + + /** + * The event's notification again, under its id and on its channel, without actions and silent (an + * update must not alert a second time). Only words that carry no event text: its title is + * [InboxNotificationText]'s headline, and the rest is the computer and the outcome (audit A52). + */ + private fun reposted(context: Context, request: Request): NotificationCompat.Builder { + val open = openSession(context, request.notificationId, request.hostId, request.event.nodeId, request.sessionTitle) + val publicVersion = NotificationCompat.Builder(context, CH_ATTENTION) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(request.headline) + .setContentText(request.computerName) + .setWhen(request.event.ts) + .build() + return NotificationCompat.Builder(context, CH_ATTENTION) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(request.headline) + .setSubText(request.computerName) + .setWhen(request.event.ts) + .setAutoCancel(true) + .setContentIntent(open) + .setOnlyAlertOnce(true) + .setSilent(true) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setPublicVersion(publicVersion) + } + + private fun post(context: Context, id: Int, notification: android.app.Notification) { + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) return + runCatching { NotificationManagerCompat.from(context).notify(id, notification) } + } +} + +class InboxWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val graph = NodetermApp.graph(applicationContext) + // Nothing could be shown: do not dial every computer every 15 minutes for it (audit A21). + if (!graph.hosts.notificationsEnabled || !InboxNotifier.canPost(applicationContext)) return Result.success() + for (host in graph.hosts.hosts.value) { + val session = graph.connections.session(host.id) + // BACKGROUND: never a first relay handshake — that would raise the desktop's approval + // dialog with nobody at the phone to compare the code (audit A05). A listing that arrives + // announces its new events itself (HostSession.refreshNow → InboxNotifier.announce, the + // path the live refresh takes too, audit A73); a failed one has nothing new to announce. + // As a background user: the connection is closed afterwards unless a screen or an answer + // from a notification still uses it (the review of A25). + session.inBackground { withTimeoutOrNull(45_000) { session.refreshNow(RelayApprovalGate.Trigger.BACKGROUND) } } + } + return Result.success() + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/AddSshHostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/AddSshHostScreen.kt new file mode 100644 index 0000000000..4344c8a8ef --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/AddSshHostScreen.kt @@ -0,0 +1,398 @@ +package dev.nodeterm.android.ui + +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.Route +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.ssh.ManualHost +import dev.nodeterm.protocol.ssh.SshProfilePath +import dev.nodeterm.protocol.ssh.SshPasswordBootstrap +import androidx.lifecycle.compose.LifecycleStartEffect +import kotlinx.coroutines.Job +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * "Add SSH server" (audit A27, part b): a computer with no pairing code — a headless Server Edition, + * which has no pairing service, or a macOS / Linux computer the phone reaches only over SSH — added by + * its address. The user can add the public key manually, or confirm the computer's SSH fingerprint + * before a one-time password login installs it. Both paths save a host only after the computer + * accepts the phone's retained key. Protocol rules live in [ManualHost] and [SshPasswordBootstrap]. + * + * Such a computer is reached over SSH only: no relay, no push. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddSshHostScreen(nav: Navigator) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val scope = rememberCoroutineScope() + // Saveable, so a rotation or a trip to the share sheet keeps what was typed. + var name by rememberSaveable { mutableStateOf("") } + var address by rememberSaveable { mutableStateOf("") } + var port by rememberSaveable { mutableStateOf(ManualHost.DEFAULT_PORT.toString()) } + var user by rememberSaveable { mutableStateOf("") } + var profile by rememberSaveable { mutableStateOf("") } + var profileError by remember { mutableStateOf(null) } + var addedId by rememberSaveable { mutableStateOf(null) } + var invalid by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + // Passwords and fingerprint approval belong only to this visible setup attempt. + var password by remember { mutableStateOf("") } + var inspection by remember { mutableStateOf(null) } + var confirmed by remember { mutableStateOf(null) } + var bootstrapJob by remember { mutableStateOf(null) } + val keyLine = remember { ManualHost.authorizedKeysLine(graph.sshIdentity) } + val install = remember(keyLine) { ManualHost.installCommand(keyLine) } + + fun resetBootstrap() { + password = "" + inspection = null + confirmed = null + } + + LifecycleStartEffect(Unit) { + onStopOrDispose { + resetBootstrap() + bootstrapJob?.cancel() + } + } + + fun checkedAddress(): ManualHost.Address? { + val checked = ManualHost.check(address, port, user, name) + invalid = checked as? ManualHost.Check.Invalid + if (checked !is ManualHost.Check.Ok) return null + profileError = profile.takeIf { it.isNotBlank() }?.let(SshProfilePath::error) + if (profileError != null) return null + ManualHost.existing(graph.hosts.hosts.value, checked.address)?.let { + error = "${checked.address.user}@${checked.address.host} is already in your list, as ${it.name}." + return null + } + return checked.address + } + + fun inspectHost() { + val target = checkedAddress() ?: return + resetBootstrap() + busy = true + error = null + bootstrapJob = scope.launch { + try { + inspection = SshPasswordBootstrap.inspect(target) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + error = e.message ?: "Couldn't inspect this computer's SSH host key." + } finally { + busy = false + bootstrapJob = null + } + } + } + + fun installWithPassword() { + val target = checkedAddress() ?: return + val approved = confirmed ?: return + if (inspection?.address != target || password.isEmpty()) return + val profilePath = SshProfilePath.fromInput(profile) + val secret = password.toCharArray() + password = "" + busy = true + error = null + bootstrapJob = scope.launch { + try { + val record = SshPasswordBootstrap.install(approved, graph.sshIdentity, secret) + .copy(sshProfilePath = profilePath) + val already = graph.hosts.addManual(record) + if (already != null) error = "${target.user}@${target.host} is already in your list, as ${already.name}." + else addedId = record.id + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + error = e.message ?: "Couldn't finish SSH key setup." + } finally { + secret.fill('\u0000') + resetBootstrap() + busy = false + bootstrapJob = null + } + } + } + + fun connect() { + resetBootstrap() + val ok = checkedAddress() ?: return + val profilePath = SshProfilePath.fromInput(profile) + busy = true + error = null + scope.launch { + try { + // Blocking: if the screen goes away meanwhile, the connect still ends (and closes its + // connection) on the IO thread, and nothing is kept. + val record = withContext(Dispatchers.IO) { ManualHost.connectFirst(ok, graph.sshIdentity) } + .copy(sshProfilePath = profilePath) + val already = graph.hosts.addManual(record) + if (already != null) error = "${ok.user}@${ok.host} is already in your list, as ${already.name}." + else addedId = record.id + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + error = e.message ?: "Couldn't connect to ${ok.user}@${ok.host}." + } finally { + busy = false + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Add SSH server") }, + navigationIcon = { IconButton(onClick = { nav.pop() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } } + ) + } + ) { padding -> + Column( + Modifier.fillMaxSize().aboveKeyboard(padding).padding(20.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + val added = addedId?.let { graph.hosts.get(it) } + if (added != null) { + Added(nav, added) + return@Column + } + Text( + "For a computer with no pairing code: a nodeterm Server Edition, or a macOS or Linux computer you reach " + + "over SSH where nodeterm runs sessions. The phone reaches it over SSH only, so not from anywhere " + + "unless its SSH is (over a VPN, say), and it gets no push notifications. To use the relay, pair the " + + "computer with nodeterm's code instead." + ) + OutlinedTextField( + value = address, + onValueChange = { resetBootstrap(); address = it }, + label = { Text("Address") }, + placeholder = { Text("devbox.local or 192.168.1.20") }, + isError = invalid?.host != null, + supportingText = invalid?.host?.let { e -> { Text(e) } }, + singleLine = true, + enabled = !busy, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth() + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = user, + onValueChange = { resetBootstrap(); user = it }, + label = { Text("User") }, + isError = invalid?.user != null, + supportingText = invalid?.user?.let { e -> { Text(e) } }, + singleLine = true, + enabled = !busy, + modifier = Modifier.weight(2f) + ) + OutlinedTextField( + value = port, + onValueChange = { resetBootstrap(); port = it }, + label = { Text("Port") }, + isError = invalid?.port != null, + supportingText = invalid?.port?.let { e -> { Text(e) } }, + singleLine = true, + enabled = !busy, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f) + ) + } + OutlinedTextField( + value = name, + onValueChange = { resetBootstrap(); name = it }, + label = { Text("Name (optional)") }, + singleLine = true, + enabled = !busy, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = profile, + onValueChange = { resetBootstrap(); profile = it }, + label = { Text("Profile folder (optional)") }, + supportingText = { Text(profileError ?: "Full path on the computer, for a custom Server data directory. Leave blank for automatic discovery.") }, + isError = profileError != null, + singleLine = true, + enabled = !busy, + modifier = Modifier.fillMaxWidth() + ) + + HorizontalDivider() + Text("1. Let this phone in", style = MaterialTheme.typography.titleMedium) + Text( + "To set up key access manually, add this line to " + + "~/.ssh/authorized_keys of that user on the computer:" + ) + Monospace(keyLine) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton(onClick = { copy(context, keyLine, "Copied the key line") }) { Text("Copy") } + OutlinedButton(onClick = { share(context, keyLine) }) { Text("Share") } + } + Text("Or run this in a terminal on the computer, logged in as that user. Running it twice adds nothing:") + Monospace(install) + OutlinedButton(onClick = { copy(context, install, "Copied the command") }) { Text("Copy the command") } + + Text("Or install the phone's key using this SSH user's password. The password is used only for setup; later connections use the phone's key.") + if (inspection == null) { + OutlinedButton(onClick = { inspectHost() }, enabled = !busy) { Text("Set up with a password") } + } else { + val candidate = inspection!! + Text("Compare this SSH host fingerprint with the computer before entering its password:") + Monospace(candidate.fingerprint) + Monospace(ManualHost.FINGERPRINT_CHECK_COMMAND) + if (confirmed == null) { + OutlinedButton(onClick = { confirmed = candidate.confirm(candidate.fingerprint) }, enabled = !busy) { + Text("I compared it — it matches") + } + } else { + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("SSH password") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + enabled = !busy, + modifier = Modifier.fillMaxWidth() + ) + Button(onClick = { installWithPassword() }, enabled = !busy && password.isNotEmpty()) { Text("Install key and connect") } + } + OutlinedButton(onClick = { resetBootstrap() }, enabled = !busy) { Text("Cancel password setup") } + } + + HorizontalDivider() + Text("2. Connect", style = MaterialTheme.typography.titleMedium) + Text( + "The phone trusts the SSH host key of the first server that accepts its key at this address, and " + + "shows it so you can compare it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Button(onClick = { connect() }, enabled = !busy) { Text("Connect") } + if (busy) { + Spacer(Modifier.size(12.dp)) + CircularProgressIndicator(Modifier.size(24.dp)) + } + } + error?.let { Text(it, color = NtColors.attention) } + } + } +} + +/** After the first connect: the host key it pinned, how to check it, and the way in. */ +@Composable +private fun Added(nav: Navigator, host: PairedHost) { + val context = LocalContext.current + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Added ${host.name}", style = MaterialTheme.typography.titleLarge) + Text("${host.user}@${host.host}" + if (host.port != ManualHost.DEFAULT_PORT) ":${host.port}" else "", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("The phone pinned this computer's SSH host key:") + Monospace(host.sshHostKeyFingerprint ?: "") + Text( + "To check it, run this on the computer. One of the lines it prints must show the same SHA256 " + + "fingerprint; if none does, forget this computer: something else answered at its address.", + style = MaterialTheme.typography.bodySmall + ) + Monospace(ManualHost.FINGERPRINT_CHECK_COMMAND) + OutlinedButton(onClick = { copy(context, ManualHost.FINGERPRINT_CHECK_COMMAND, "Copied the command") }) { + Text("Copy the command") + } + Text( + "It is reached over SSH only. Without push, the phone checks it for approvals and questions about " + + "every 15 minutes in the background, and live while you look at it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Button(onClick = { + nav.replaceAll(Route.Hosts) + nav.push(Route.Host(host.id)) + }, modifier = Modifier.fillMaxWidth()) { Text("Open ${host.name}") } +} + +@Composable +private fun Monospace(text: String) { + SelectionContainer { + Text( + text, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) + } +} + +/** A failed clipboard write is a binder call that can throw (as in the terminal's copy, A53): said, not crashed. */ +private fun copy(context: Context, text: String, done: String) { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val ok = cm != null && runCatching { cm.setPrimaryClip(ClipData.newPlainText("nodeterm", text)) }.isSuccess + Toast.makeText(context, if (ok) done else "Couldn't copy. Select the text and copy it instead.", Toast.LENGTH_SHORT).show() +} + +/** Hand the key line to another app (a message to yourself, a note) to paste on the computer. */ +private fun share(context: Context, text: String) { + val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text) + try { + context.startActivity(Intent.createChooser(send, null)) + } catch (_: ActivityNotFoundException) { + Toast.makeText(context, "No app can share it. Copy it instead.", Toast.LENGTH_SHORT).show() + } catch (_: RuntimeException) { + Toast.makeText(context, "Couldn't share it. Copy it instead.", Toast.LENGTH_SHORT).show() + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/AllComputersScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/AllComputersScreen.kt new file mode 100644 index 0000000000..a4758a5678 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/AllComputersScreen.kt @@ -0,0 +1,275 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleStartEffect +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.conn.ConnState +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.RelayApprovalGate.Trigger +import dev.nodeterm.protocol.model.AllComputers +import dev.nodeterm.protocol.model.ComputerLabel +import dev.nodeterm.protocol.model.ComputerListing +import dev.nodeterm.protocol.model.InboxFeed +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield + +/** + * Every paired computer's Inbox and Usage on one screen (audit A55), like iOS's Agents and Usages tabs + * (docs/mobile-usage-inbox.md: "merged events across connections, newest first"; "one section per + * paired connection that reports `usage`"). Each card names its computer, and Open / Approve / Answer + * go to that computer's own session ([InboxFeedList]). The rules (merge, order, labels, sections) are + * [AllComputers], pure and tested; this only draws them. + * + * While visible, every paired computer is watched through its normal connect path and re-listed + * every 8 seconds. Leaving or backgrounding the screen cancels its watchers; other visible screens + * and in-flight background actions keep their own connection ownership. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AllComputersScreen(nav: Navigator) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val hosts by graph.hosts.hosts.collectAsState() + val listings = rememberListings(hosts) + val hostIds = remember(hosts) { hosts.map { it.id } } + val uiScope = rememberCoroutineScope() + // Saved under this back-stack entry's key, so it survives a terminal opened from a card (A43). + var tab by rememberSaveable { mutableIntStateOf(0) } + val tabStates = rememberSaveableStateHolder() + + // Every computer re-listed through its normal connect path, so a relay leg goes through + // RelayApprovalGate. As the app's own FOREGROUND refresh (Trigger.AUTO), not a tap on that computer: + // the user is looking, so a computer that has never approved this phone may show its code below, + // but a computer whose approval was refused or went unanswered keeps that hold (A05/A23). Lifting + // it would raise the desktop's dialog again on every visit to this screen, on a computer the user + // did not ask about and may not be at; its own Try again below asks (Trigger.USER). + fun refreshAll() = hostIds.forEach { graph.connections.session(it).refresh(Trigger.AUTO) } + + // On ON_START, like a computer's own screen starts watching: opening the screen, coming back to the + // app, and coming back from a terminal re-list. Launched on the UI dispatcher, i.e. after this + // frame's effects: the Inbox tab registers what it shows (A73) before the first listing is asked for. + // Key each watcher separately: changing the paired host set leaves the unaffected loops alone. + for (hostId in hostIds) key(hostId) { + LifecycleStartEffect(hostId) { + val session = graph.connections.session(hostId) + var watching = false + val starting = uiScope.launch { + yield() // let this STARTED frame register every visible Inbox first (A73) + session.startWatching(Trigger.AUTO) + watching = true + } + onStopOrDispose { + starting.cancel() + if (watching) session.stopWatching(closeWhenUnused = true) + } + } + } + + val feed = AllComputers.feed(listings) + val needsYou = feed.actionable.size + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text("All computers") + Text( + "Inbox and usage from ${hosts.size} computer" + (if (hosts.size == 1) "" else "s"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { IconButton(onClick = { nav.pop() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + actions = { IconButton(onClick = { refreshAll() }) { Icon(Icons.Filled.Refresh, "Refresh") } } + ) + } + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + // What stands between a computer and its cards: connecting, a code to approve, an error. + for (l in listings) key(l.computer.hostId) { ComputerStatus(l.computer, graph.connections.session(l.computer.hostId)) } + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(if (needsYou > 0) "Inbox ($needsYou)" else "Inbox") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Usage") }) + } + tabStates.SaveableStateProvider(tab) { + when (tab) { + 0 -> AllInbox(nav, hostIds, feed) + else -> AllUsage(AllComputers.usage(listings)) + } + } + } + } +} + +/** + * Each paired computer's cached listing (its session's latest snapshot), labelled ([AllComputers.labels]), + * in [hosts] order. It reads what the sessions already hold and updates as they re-list: nothing here + * connects, so the computers list can show its counts without a dial. + */ +@Composable +internal fun rememberListings(hosts: List): List { + val graph = NodetermApp.graph(LocalContext.current) + val labels = remember(hosts) { AllComputers.labels(hosts) } + val initial = remember(labels) { labels.map { ComputerListing(it, graph.connections.session(it.hostId).snapshot.value) } } + val flow = remember(labels) { + val snapshots = labels.map { graph.connections.session(it.hostId).snapshot } + if (snapshots.isEmpty()) flowOf(emptyList()) + else combine(snapshots) { latest: Array -> labels.mapIndexed { i, l -> ComputerListing(l, latest[i]) } } + } + val listings by flow.collectAsState(initial) + return listings +} + +/** The merged Inbox. While it is on screen, so is every computer's Inbox (A73). */ +@Composable +private fun AllInbox(nav: Navigator, hostIds: List, feed: InboxFeed) { + val graph = NodetermApp.graph(LocalContext.current) + // While this tab is on screen, a listing of ANY computer announces none of its events; it records + // them as seen, so no later check announces them either, as a computer's own Inbox tab does (A73). + LifecycleStartEffect(hostIds) { + val showing = hostIds.map { graph.connections.session(it).onScreen.showInbox() } + onStopOrDispose { showing.forEach { it.close() } } + } + InboxFeedList(nav, feed, showComputer = true, emptyText = "Nothing needs you on any computer.") +} + +/** One section per computer that reports usage: its label and when it was measured, then its accounts. */ +@Composable +private fun AllUsage(sections: List) { + if (sections.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + "None of your computers is reporting usage. (Usage is shared by nodeterm on each computer for its local accounts.)", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + for (section in sections) { + val hostId = section.computer.hostId + item(key = "h-$hostId") { + Row(Modifier.fillMaxWidth().padding(top = 6.dp), verticalAlignment = Alignment.Bottom) { + Text( + section.computer.label, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + updatedAgo(section.usage.updatedAt)?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + // By position: the accounts of one computer are not guaranteed distinct by id and agent. + itemsIndexed(section.usage.accounts, key = { i, _ -> "u-$hostId-$i" }) { _, account -> + UsageCard(account, section.status) + } + } + } +} + +/** + * What keeps a computer's cards from this screen, if anything: it is connecting, the computer shows an + * approval code (the relay's first connect, or the relay leg an answer opened next to SSH), its connect + * failed, or its last listing did. That last one also when the failure dropped the connection, which + * leaves the session Idle ([ConnState.showsListError]): nothing re-lists here on its own, so without + * the strip its cached cards would stay on screen looking current. Nothing while it is listed. + */ +@Composable +private fun ComputerStatus(computer: ComputerLabel, session: HostSession) { + val state by session.state.collectAsState() + val listError by session.lastError.collectAsState() + val relayApproval by session.relayApproval.collectAsState() + when (val s = state) { + is ConnState.AwaitingApproval -> ApprovalFor(computer, s.sas) + is ConnState.Connecting -> Text( + "${computer.label}: ${s.detail}", + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + // Try again is the user asking about THIS computer (Trigger.USER): it lifts a held approval. + is ConnState.Failed -> Problem("${computer.label}: ${s.message}") { session.refresh(Trigger.USER) } + is ConnState.Connected, ConnState.Idle -> Unit + } + // Its last listing failed, also when that dropped the connection and left it Idle. + if (state.showsListError) listError?.let { Problem("${computer.label}: $it") { session.refresh(Trigger.USER) } } + relayApproval?.let { if (state !is ConnState.AwaitingApproval) ApprovalFor(computer, it) } +} + +@Composable +private fun ApprovalFor(computer: ComputerLabel, sas: String) { + Column(Modifier.fillMaxWidth()) { + Text( + computer.label, + Modifier.fillMaxWidth().background(NtColors.panel2).padding(start = 16.dp, end = 16.dp, top = 10.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + ApprovalCode(sas) + } +} + +@Composable +private fun Problem(text: String, onRetry: () -> Unit) { + Row( + Modifier.fillMaxWidth().background(NtColors.attention.copy(alpha = 0.12f)).padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text, style = MaterialTheme.typography.bodySmall, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + TextButton(onClick = onRetry) { Text("Try again") } + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt new file mode 100644 index 0000000000..f5eb03b148 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/BoardTab.kt @@ -0,0 +1,346 @@ +package dev.nodeterm.android.ui + +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.Route +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.model.KanbanLabel +import dev.nodeterm.protocol.model.NodeInfo +import dev.nodeterm.protocol.model.NodeKind +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.coroutines.launch + +/** The board palette (`KANBAN_LABEL_COLORS`, @shared/kanban-labels), for chips and for "create". */ +private val LABEL_COLORS = listOf("gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red") + +private fun labelColor(name: String): Color = when (name) { + "gray" -> Color(0xFF8E8E93) + "brown" -> Color(0xFFA2845E) + "orange" -> Color(0xFFFF9F0A) + "yellow" -> Color(0xFFFFD60A) + "green" -> Color(0xFF32D74B) + "blue" -> Color(0xFF0A84FF) + "purple" -> Color(0xFFBF5AF2) + "pink" -> Color(0xFFFF375F) + "red" -> Color(0xFFFF453A) + else -> Color(0xFF636366) +} + +private const val UNGROUPED = "__ungrouped__" + +/** + * The project's kanban board, as the desktop's `KanbanView` derives it: cards are the session nodes + * (terminal / sticky / browser — `toKanbanSession`), the virtual Ungrouped column holds every card + * with no (or a dangling) assignment, and assignment is board metadata only. Moves and labels go + * through the host's `projects.*` verbs — never a file write from the phone. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun BoardTab(nav: Navigator, hostId: String, session: HostSession, snapshot: ProjectsSnapshot) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val projects = snapshot.openProjects() + // Saveable, so the chosen project is still showing after a terminal opened from a card (audit A43). + var projectId by rememberSaveable { mutableStateOf(null) } + val project = projects.firstOrNull { it.id == projectId } ?: projects.firstOrNull() + var projectMenu by remember { mutableStateOf(false) } + var moving by remember { mutableStateOf(null) } + var labeling by remember { mutableStateOf(null) } + // Board writes are nodeterm the app's (the relay's `projects.*` verbs): on the LAN that is the + // relay leg opened next to the SSH connection, on a tap (audit A26). Where this phone has no + // relay leg the controls stay, disabled, and say why; so they do on the board of a project another + // desktop drives over SSH, which is that desktop's to write (A27). + // Re-asked on each listing (it says whether the computer advertises its relay right now) and when + // a late adoption stores the phone's relay leg in the background (the secrets' revision, the host + // record), which no listing announces. Asking stores nothing and decrypts nothing (audit A47). + val graph = NodetermApp.graph(context) + val secretsRevision by graph.secure.revision.collectAsState() + val hostRecords by graph.hosts.hosts.collectAsState() + val boardRoute = remember(snapshot, project, secretsRevision, hostRecords) { session.route(Capability.BOARD_WRITES, project) } + val readOnlyReason = (boardRoute as? LegRouting.Leg.Unavailable)?.reason + + if (project == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("No projects yet.") } + return + } + + /** The leg that writes the board: the open connection, or the relay leg next to SSH (a tap). */ + suspend fun boardConnection() = session.connectionFor(Capability.BOARD_WRITES, project = project) + + fun write(label: String, block: suspend () -> Unit) { + scope.launch { + try { + block() + session.refreshNow() + } catch (e: Exception) { + Toast.makeText(context, "$label: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Box { + OutlinedButton(onClick = { projectMenu = true }) { + ColorDot(parseHex(project.color, NtColors.accent)) + Spacer(Modifier.width(8.dp)) + Text(project.name) + } + DropdownMenu(expanded = projectMenu, onDismissRequest = { projectMenu = false }) { + projects.forEach { p -> + DropdownMenuItem(text = { Text(p.name) }, onClick = { + projectId = p.id + projectMenu = false + }) + } + } + } + Spacer(Modifier.width(8.dp)) + // The project's source control (audit A29); the screen says why when it cannot open. + TextButton(onClick = { nav.push(Route.SourceControl(hostId, project.id)) }) { Text("Source control") } + } + if (readOnlyReason != null) { + Text( + "Read-only here. $readOnlyReason", + Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + val cards = project.nodes.filter { it.kind == NodeKind.TERMINAL || it.kind == NodeKind.STICKY || it.kind == NodeKind.BROWSER } + val board = project.board + val columns = board?.columns?.map { it.id to it.title } ?: emptyList() + val known = columns.map { it.first }.toSet() + val byColumn = cards.groupBy { card -> board?.columnOf(card.id)?.takeIf { it in known } ?: UNGROUPED } + // Order within a column = the assignments' relative order (Ungrouped keeps canvas order). + val order = board?.assignments?.map { it.first }?.withIndex()?.associate { it.value to it.index } ?: emptyMap() + val allColumns = listOf(UNGROUPED to "Ungrouped") + + (columns.ifEmpty { dev.nodeterm.protocol.model.KanbanBoard.DEFAULT_COLUMN_TITLES.map { "default:$it" to it } }) + + Row(Modifier.fillMaxSize().horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp)) { + allColumns.forEach { (colId, title) -> + val colCards = byColumn[colId].orEmpty().sortedBy { order[it.id] ?: Int.MAX_VALUE } + Column( + Modifier + .width(260.dp) + .fillMaxHeight() + .padding(4.dp) + .background(NtColors.panel, RoundedCornerShape(10.dp)) + .padding(8.dp) + ) { + val color = board?.columns?.firstOrNull { it.id == colId }?.color + Row(verticalAlignment = Alignment.CenterVertically) { + if (color != null) { + ColorDot(parseHex(color)) + Spacer(Modifier.width(6.dp)) + } + Text(title, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.width(6.dp)) + Text("${colCards.size}", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(top = 8.dp)) { + items(colCards, key = { it.id }) { card -> + BoardCard( + card, project, snapshot, + onClick = { + if (card.kind == NodeKind.TERMINAL) nav.push(Route.Terminal(hostId, card.id, displayTitle(card, snapshot))) + }, + // Long-press opens the card's actions even when they cannot run, + // so the disabled controls can say why (audit A26). + onLongClick = { moving = card } + ) + } + } + } + } + } + } + + moving?.let { card -> + AlertDialog( + onDismissRequest = { moving = null }, + title = { Text(displayTitle(card, snapshot)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + readOnlyReason?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + val writable = readOnlyReason == null + Text("Move to", style = MaterialTheme.typography.labelLarge) + val cols = project.board?.columns?.map { it.id to it.title } + TextButton(enabled = writable, onClick = { + moving = null + write("Move") { boardConnection().setCardColumn(project.id, card.id, null) } + }) { Text("Ungrouped") } + if (cols == null) { + // No board yet: ask the host to seed the default one, then move into its column. + dev.nodeterm.protocol.model.KanbanBoard.DEFAULT_COLUMN_TITLES.forEachIndexed { i, t -> + TextButton(enabled = writable, onClick = { + moving = null + write("Move") { + val conn = boardConnection() + val seeded = conn.ensureBoard(project.id) ?: error("this project can't have a board written") + val target = seeded.firstOrNull { it.title == t } ?: seeded.getOrNull(i) ?: error("no such column") + conn.setCardColumn(project.id, card.id, target.id) + } + }) { Text(t) } + } + } else { + cols.forEach { (id, t) -> + TextButton(enabled = writable, onClick = { + moving = null + write("Move") { boardConnection().setCardColumn(project.id, card.id, id) } + }) { Text(t) } + } + } + TextButton(enabled = writable, onClick = { + labeling = card + moving = null + }) { Text("Labels…") } + } + }, + confirmButton = { TextButton(onClick = { moving = null }) { Text("Close") } } + ) + } + + labeling?.let { card -> + LabelsDialog( + card = card, + project = project, + onDismiss = { labeling = null }, + onApply = { edit -> + labeling = null + write("Labels") { boardConnection().editCardLabels(project.id, card.id, edit) } + } + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun BoardCard(card: NodeInfo, project: ProjectInfo, snapshot: ProjectsSnapshot, onClick: () -> Unit, onLongClick: () -> Unit) { + val meta = project.board?.metaOf(card.id) + val labels = meta?.labels.orEmpty().mapNotNull { id -> project.board?.labels?.firstOrNull { it.id == id } } + Column( + Modifier + .fillMaxWidth() + .background(NtColors.panel2, RoundedCornerShape(8.dp)) + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + ColorDot(parseHex(card.color)) + Spacer(Modifier.width(6.dp)) + Text(if (card.kind == NodeKind.STICKY) (card.text ?: card.title).take(80) else displayTitle(card, snapshot), maxLines = 2) + } + if (labels.isNotEmpty() || meta?.priority != null) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + meta?.priority?.let { LabelChip(it, if (it == "urgent" || it == "high") "red" else "gray") } + labels.forEach { LabelChip(it.name, it.color) } + } + } + StatusBadge(snapshot.statusOf(card.id)?.bucket) + } +} + +@Composable +private fun LabelChip(text: String, color: String) { + val c = labelColor(color) + Text( + text, + fontSize = 11.sp, + color = c, + modifier = Modifier.background(c.copy(alpha = 0.18f), RoundedCornerShape(4.dp)).padding(horizontal = 6.dp, vertical = 1.dp) + ) +} + +@Composable +private fun LabelsDialog(card: NodeInfo, project: ProjectInfo, onDismiss: () -> Unit, onApply: (CardLabelEdit) -> Unit) { + val palette: List = project.board?.labels.orEmpty() + val current = project.board?.metaOf(card.id)?.labels.orEmpty().toSet() + var selected by remember(card.id) { mutableStateOf(current) } + var newName by remember { mutableStateOf("") } + var newColor by remember { mutableStateOf("blue") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Labels") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + palette.forEach { l -> + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = l.id in selected, onCheckedChange = { on -> selected = if (on) selected + l.id else selected - l.id }) + LabelChip(l.name, l.color) + } + } + OutlinedTextField(value = newName, onValueChange = { newName = it.take(60) }, label = { Text("New label") }, singleLine = true) + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + LABEL_COLORS.forEach { c -> + TextButton(onClick = { newColor = c }) { + Text(if (c == newColor) "● $c" else c, color = labelColor(c), fontSize = 12.sp) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { + val add = (selected - current).toList() + val remove = (current - selected).toList() + val create = if (newName.isNotBlank()) listOf(newName.trim() to newColor) else emptyList() + if (add.isEmpty() && remove.isEmpty() && create.isEmpty()) onDismiss() + else onApply(CardLabelEdit(add = add, remove = remove, create = create)) + }) { Text("Apply") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/DictationController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/DictationController.kt new file mode 100644 index 0000000000..5075ae0560 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/DictationController.kt @@ -0,0 +1,179 @@ +package dev.nodeterm.android.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.vector.ImageVector +import dev.nodeterm.protocol.model.Dictation +import dev.nodeterm.android.data.DictationPreferences + +/** + * The terminal input bar's mic (audit A59): Android's speech recognizer, driven by [Dictation], the + * tested state machine. It writes what it hears into the draft through [setDraft], and only that: it + * cannot send (it has no TerminalController), so the draft reaches the pane only on the user's Send, + * the desktop's rule for its own dictation. + * + * The recognizer is the phone's recognition service (Google's on most phones, which may send the audio + * to its servers, unlike the desktop's on-device Whisper). System default leaves EXTRA_LANGUAGE out. + * An explicit Settings language is frozen at Start and sent as the documented BCP47 extra. The picker + * uses locale display names, not a list of supported speech models; the service can still refuse an + * unavailable/unsupported language and its actual error is shown without submitting the draft. + * + * Everything here runs on the main thread, where SpeechRecognizer must be used: the screen's + * clicks, the permission result, and the recognizer's callbacks (delivered on the main thread). A + * recognizer is made per dictation and released when it ends, and its callbacks are dropped once it + * is no longer the current one, so a late callback of a cancelled recognizer cannot reach the next + * dictation's draft. + * + * None of this has run on a device. + */ +class DictationController(private val context: Context, private val setDraft: (String) -> Unit) { + /** + * Whether the phone has a speech recognition service at all. When it has none the mic button is + * not shown (the keyboard's own voice typing, where it has one, still works in the input bar). On + * Android 11 and later this sees the service only through the manifest's `` entry for + * `android.speech.RecognitionService`. + */ + val available: Boolean = runCatching { SpeechRecognizer.isRecognitionAvailable(context) }.getOrDefault(false) + + var state by mutableStateOf(Dictation.State()) + private set + + val active: Boolean get() = state.active + + private var recognizer: SpeechRecognizer? = null + private val preferences = DictationPreferences(context) + + /** The mic was tapped with the microphone permission granted. */ + fun start(draft: String) = on(Dictation.Event.Start(draft, preferences.languageTag)) + + /** The mic was tapped while listening. */ + fun stop() = on(Dictation.Event.Stop) + + /** The microphone permission was refused. */ + fun denied() = on(Dictation.Event.Denied) + + /** The draft changed other than by dictation (the user typed, or Send cleared it). */ + fun edited() = on(Dictation.Event.Edited) + + /** The error's OK. */ + fun dismiss() = on(Dictation.Event.Dismiss) + + /** The screen stopped: nothing listens in the background. What the draft shows stays. */ + fun cancel() = on(Dictation.Event.Cancel) + + /** The screen went away. */ + fun dispose() { + on(Dictation.Event.Cancel) + release() + } + + private fun on(event: Dictation.Event) { + val step = Dictation.step(state, event) + state = step.state + step.draft?.let(setDraft) + when (step.effect) { + Dictation.Effect.START -> listen() + Dictation.Effect.STOP -> recognizer?.let { runCatching { it.stopListening() } } + Dictation.Effect.CANCEL -> { + recognizer?.let { runCatching { it.cancel() } } + release() + } + Dictation.Effect.RELEASE -> release() + null -> Unit + } + } + + private fun listen() { + release() + val rec = try { + SpeechRecognizer.createSpeechRecognizer(context) + } catch (e: Exception) { + on(Dictation.Event.Failed(Dictation.Failure.UNKNOWN)) + return + } + recognizer = rec + rec.setRecognitionListener(Listener(rec)) + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + .putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + .putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + .putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1) + .putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) + state.languageTag?.let { intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) } + try { + rec.startListening(intent) + } catch (e: Exception) { + on(Dictation.Event.Failed(Dictation.Failure.UNKNOWN)) + } + } + + private fun release() { + val rec = recognizer ?: return + recognizer = null + runCatching { rec.destroy() } + } + + private inner class Listener(private val rec: SpeechRecognizer) : RecognitionListener { + private fun current() = recognizer === rec + + override fun onPartialResults(partialResults: Bundle?) { + if (current()) on(Dictation.Event.Heard(firstOf(partialResults))) + } + + override fun onResults(results: Bundle?) { + if (current()) on(Dictation.Event.Result(firstOf(results))) + } + + override fun onError(error: Int) { + if (current()) on(Dictation.Event.Failed(Dictation.Failure.of(error))) + } + + override fun onReadyForSpeech(params: Bundle?) {} + override fun onBeginningOfSpeech() {} + override fun onRmsChanged(rmsdB: Float) {} + override fun onBufferReceived(buffer: ByteArray?) {} + override fun onEndOfSpeech() {} + override fun onEvent(eventType: Int, params: Bundle?) {} + } + + private fun firstOf(bundle: Bundle?): String? = + bundle?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull { it.isNotBlank() } +} + +/** + * Material's "mic" icon. Its path is copied here because material-icons-core, the icon set the app + * ships, does not have it, and the extended set is several MB of icons for this one. + */ +val MicIcon: ImageVector by lazy { + materialIcon(name = "Filled.Mic") { + materialPath { + moveTo(12.0f, 14.0f) + curveToRelative(1.66f, 0.0f, 2.99f, -1.34f, 2.99f, -3.0f) + lineTo(15.0f, 5.0f) + curveToRelative(0.0f, -1.66f, -1.34f, -3.0f, -3.0f, -3.0f) + reflectiveCurveTo(9.0f, 3.34f, 9.0f, 5.0f) + verticalLineToRelative(6.0f) + curveToRelative(0.0f, 1.66f, 1.34f, 3.0f, 3.0f, 3.0f) + close() + moveTo(17.3f, 11.0f) + curveToRelative(0.0f, 3.0f, -2.54f, 5.1f, -5.3f, 5.1f) + reflectiveCurveTo(6.7f, 14.0f, 6.7f, 11.0f) + lineTo(5.0f, 11.0f) + curveToRelative(0.0f, 3.41f, 2.72f, 6.23f, 6.0f, 6.72f) + lineTo(11.0f, 21.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(-3.28f) + curveToRelative(3.28f, -0.48f, 6.0f, -3.3f, 6.0f, -6.72f) + horizontalLineToRelative(-1.7f) + close() + } + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/DictationLanguageDialog.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/DictationLanguageDialog.kt new file mode 100644 index 0000000000..18bd66aafa --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/DictationLanguageDialog.kt @@ -0,0 +1,53 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.nodeterm.protocol.model.DictationLanguage +import java.util.Locale + +@Composable +internal fun DictationLanguageDialog(selected: String?, onChoose: (String?) -> Unit, onDismiss: () -> Unit) { + var query by remember { mutableStateOf("") } + val choices = remember(selected) { DictationLanguage.choices(Locale.getAvailableLocales().toList(), selected = selected) } + val matches = DictationLanguage.search(choices, query, selected) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Dictation language") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Availability depends on your phone's speech service and its language models.", style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = query, onValueChange = { query = it.take(120) }, + label = { Text("Search languages") }, singleLine = true, modifier = Modifier.fillMaxWidth() + ) + Column(Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState())) { + TextButton(onClick = { onChoose(null) }) { Text((if (selected == null) "✓ " else "") + "System default") } + matches.forEach { choice -> + TextButton(onClick = { onChoose(choice.tag) }) { + Text((if (selected == choice.tag) "✓ " else "") + choice.label) + } + } + if (matches.isEmpty()) Text("No matching languages.") + if (matches.size == DictationLanguage.MAX_VISIBLE) Text("Type a language or region to narrow the list.", style = MaterialTheme.typography.bodySmall) + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } } + ) +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt new file mode 100644 index 0000000000..259640732c --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt @@ -0,0 +1,342 @@ +package dev.nodeterm.android.ui + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.conn.ConnState +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.host.ManagedSessionChoice +import dev.nodeterm.protocol.host.ManagedSessionCreation +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.AllComputers +import dev.nodeterm.protocol.model.NewSessionChoice +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HostScreen(nav: Navigator, hostId: String, initialTab: Int) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val host = graph.hosts.get(hostId) + if (host == null) { + LaunchedEffect(hostId) { nav.pop() } + return + } + val session = remember(hostId) { graph.connections.session(hostId) } + val state by session.state.collectAsState() + val snapshot by session.snapshot.collectAsState() + // Saved under this back-stack entry's key (AppContent), so it survives a terminal pushed on top + // and a recreation (audit A43); [initialTab] only picks the tab a NEW entry opens on. + var tab by rememberSaveable { mutableIntStateOf(initialTab) } + // Each tab's own saved state (scroll position, the Board's project, the Inbox's archive toggle), + // kept while another tab shows. The holder itself lives in this entry's saved state. + val tabStates = rememberSaveableStateHolder() + var newSession by remember { mutableStateOf(false) } + val managedCreation = session.managedSessionCreation + val managedState by managedCreation.state.collectAsState() + var managedTicket by remember { mutableStateOf(null) } + val creation = session.sshTerminalCreation + val creationState by creation.state.collectAsState() + var terminalTicket by remember { mutableStateOf(null) } + var hostStarted by remember { mutableStateOf(false) } + val screenKey = remember { nav.top.key } + fun dismissTerminal() { + terminalTicket?.let(creation::hide) + terminalTicket = null + } + fun openCreated(ticket: Long) { + if (!hostStarted || terminalTicket != ticket || nav.top.key != screenKey || graph.hosts.get(hostId) == null) return + val request = creation.takeReady(ticket) ?: return + dismissTerminal() + nav.push(dev.nodeterm.android.Route.Terminal(hostId, request.nodeId, "Terminal")) + } + fun dismissNewSession() { + managedTicket?.let(managedCreation::hide) + managedTicket = null + newSession = false + } + fun openManaged(ticket: Long) { + if (!hostStarted || managedTicket != ticket || nav.top.key != screenKey || graph.hosts.get(hostId) == null) return + try { + val ready = managedCreation.takeReady(ticket) ?: return + dismissNewSession() + nav.push(dev.nodeterm.android.Route.Terminal(hostId, ready.receipt.nodeId, ready.request.choice.title ?: "Terminal")) + } catch (e: Exception) { Toast.makeText(context, e.message ?: "Couldn't save the creation result.", Toast.LENGTH_LONG).show() } + } + DisposableEffect(creation) { + onDispose { terminalTicket?.let(creation::hide) } + } + DisposableEffect(managedCreation) { + onDispose { managedTicket?.let(managedCreation::hide) } + } + + // Watch (the 8 s poll) only while the screen is STARTED: a backgrounded app used to keep + // polling, and keep its relay stream open, for as long as the process ran (audit A18). + LifecycleStartEffect(hostId) { + hostStarted = true + if (terminalTicket != null) terminalTicket = creation.show() + if (managedTicket != null) managedTicket = managedCreation.show() + session.startWatching() + onStopOrDispose { + hostStarted = false + terminalTicket?.let(creation::hide) + managedTicket?.let(managedCreation::hide) + session.stopWatching() + } + } + + // One count for this tab, the computer's row in the list and the All computers screen (A55). + val needsYou = AllComputers.needsYou(snapshot) + // A current selected profile can create and hand off a managed terminal directly over SSH. + // Older hosts keep the existing relay creation route and its visible refusal reason (A26). + val offersSshTerminal = (state as? ConnState.Connected)?.kind == TransportKind.SSH + val managedNew = offersSshTerminal && session.connection?.capabilities?.managedCreate == true + val managedPending = managedState is ManagedSessionCreation.State.Creating || managedState is ManagedSessionCreation.State.Ready || managedState is ManagedSessionCreation.State.Uncertain + val offersNew = state is ConnState.Connected && NewSessionChoice.offeredProjects(snapshot).isNotEmpty() || managedPending + fun showNewSession() { + managedTicket = if (managedNew || managedPending) managedCreation.show() else null + newSession = true + } + // Re-asked when what the routing reads changes: the connection, each listing (it says whether the + // computer advertises its relay right now), and the stored relay leg — a late adoption stores a + // token in the background, which moves the secrets' revision and the host record, not the listing. + val secretsRevision by graph.secure.revision.collectAsState() + val hostRecords by graph.hosts.hosts.collectAsState() + val newRoute = remember(state, snapshot, secretsRevision, hostRecords) { session.route(Capability.REGISTER_NODE) } + val newBlocked = if (managedNew || managedPending) null else (newRoute as? LegRouting.Leg.Unavailable)?.reason + val relayApproval by session.relayApproval.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(host.name) + Text( + when (val s = state) { + is ConnState.Connected -> if (s.kind == TransportKind.SSH) host.sshLegName else "Through the relay · end-to-end encrypted" + is ConnState.Connecting -> s.detail + is ConnState.AwaitingApproval -> "Waiting for approval" + is ConnState.Failed -> "Offline" + ConnState.Idle -> "" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { IconButton(onClick = { nav.pop() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + actions = { IconButton(onClick = { session.refresh() }) { Icon(Icons.Filled.Refresh, "Refresh") } } + ) + }, + floatingActionButton = { + if (tab == 0 && (offersSshTerminal || offersNew)) { + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (offersSshTerminal) { + ExtendedFloatingActionButton( + onClick = { terminalTicket = creation.show() }, + icon = { Icon(Icons.Filled.Add, null) }, + text = { Text("New terminal") } + ) + } + if (offersNew) { + if (newBlocked == null) { + if (offersSshTerminal) { + Button(onClick = { showNewSession() }) { Text("New session") } + } else { + ExtendedFloatingActionButton( + onClick = { showNewSession() }, + icon = { Icon(Icons.Filled.Add, null) }, + text = { Text("New session") } + ) + } + } else { + Button(onClick = {}, enabled = false) { + Icon(Icons.Filled.Add, null) + Text("New session") + } + } + } + } + } + } + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + ConnectionBanner(state, session) + // The relay leg a board edit or New session opened next to the SSH connection is waiting + // for the computer's approval dialog (its first dial on a desktop that has not pinned us). + relayApproval?.let { if (state !is ConnState.AwaitingApproval) ApprovalCode(it) } + // Connected through the relay because the server at the paired address presented a + // different SSH host key (audit A49/A74): the relay proves the computer, but the change + // itself must stay visible. + val sshWarning by session.sshWarning.collectAsState() + val warn = sshWarning + if (state is ConnState.Connected && warn != null) { + Text( + warn, + Modifier.fillMaxWidth().background(NtColors.warning.copy(alpha = 0.12f)).padding(12.dp), + style = MaterialTheme.typography.bodySmall + ) + } + // A listing that failed on a connection that is still up (e.g. nodeterm's data not found + // over SSH) used to be invisible: the screen just stayed empty (audit A31). Also once the + // failure dropped the connection (Idle): one the phone cannot parse fails every poll the + // same way, and the screen showed nothing between them (the review of A55). + val listError by session.lastError.collectAsState() + val err = listError + if (state.showsListError && err != null) { + Text( + err, + Modifier.fillMaxWidth().background(NtColors.attention.copy(alpha = 0.12f)).padding(12.dp), + style = MaterialTheme.typography.bodySmall + ) + } + // A Server Edition's install metadata (the mirror's `server` block, A27): which version + // this computer runs, the question an installed server's updates raise. + snapshot.status?.server?.describe()?.let { + Text( + it, + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Sessions") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Board") }) + Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text(if (needsYou > 0) "Inbox ($needsYou)" else "Inbox") }) + Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("Usage") }) + } + tabStates.SaveableStateProvider(tab) { + when (tab) { + 0 -> SessionsTab(nav, hostId, session, snapshot, newSessionNote = newBlocked?.takeIf { offersNew }) + 1 -> BoardTab(nav, hostId, session, snapshot) + 2 -> InboxTab(nav, hostId, session, snapshot) + else -> UsageTab(snapshot) + } + } + } + } + + terminalTicket?.let { ticket -> + SshTerminalDialog( + snapshot = snapshot, + state = creationState, + onDismiss = { dismissTerminal() }, + onCreate = { cwd -> + creation.submit(ticket, cwd) { + // AppGraph.scope uses Default. Compose state and navigation belong to Main. + withContext(Dispatchers.Main) { openCreated(ticket) } + } + }, + onOpen = { openCreated(ticket) } + ) + } + + if (newSession) { + NewSessionDialog( + snapshot = snapshot, + onDismiss = { dismissNewSession() }, + onCreate = { request -> + newSession = false + PendingLaunches.put(request) + nav.push(dev.nodeterm.android.Route.Terminal(hostId, request.nodeId, request.title)) + Toast.makeText(context, "Starting ${request.title}…", Toast.LENGTH_SHORT).show() + }, + onManagedCreate = managedTicket?.let { ticket -> { choice: ManagedSessionChoice -> + managedCreation.submit(ticket, choice) { withContext(Dispatchers.Main) { openManaged(ticket) } } + Unit + } }, + managedState = managedState.takeIf { managedTicket != null }, + onOpenManaged = { managedTicket?.let(::openManaged) }, + onCheckedManaged = { + try { managedTicket?.let(managedCreation::acknowledgeChecked) } + catch (e: Exception) { Toast.makeText(context, e.message ?: "Couldn't save the pending creation.", Toast.LENGTH_LONG).show() } + } + ) + } +} + +@Composable +private fun ConnectionBanner(state: ConnState, session: HostSession) { + when (state) { + is ConnState.AwaitingApproval -> ApprovalCode(state.sas) + is ConnState.Failed -> Column( + Modifier.fillMaxWidth().background(NtColors.attention.copy(alpha = 0.12f)).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(state.message, style = MaterialTheme.typography.bodySmall) + Row { Button(onClick = { session.refresh() }) { Text("Try again") } } + } + else -> Unit + } +} + +@Composable +internal fun ApprovalCode(sas: String) { + Column( + Modifier.fillMaxWidth().background(NtColors.panel2).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text("Approve this phone on your computer", fontWeight = FontWeight.SemiBold) + Text( + sas, + fontSize = 34.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = NtColors.accent + ) + Text( + "nodeterm on your computer is showing a code. Approve only if it matches this one — a different code " + + "means someone is in the middle. You only do this once per phone.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt new file mode 100644 index 0000000000..c476bac2c8 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt @@ -0,0 +1,258 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.Route +import dev.nodeterm.android.conn.ConnState +import dev.nodeterm.android.data.SecureStore +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.AllComputers +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.ssh.ManualHost + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun HostsScreen(nav: Navigator) { + val graph = NodetermApp.graph(LocalContext.current) + val hosts by graph.hosts.hosts.collectAsState() + // Re-asks each row's relay-token check when a token is stored or removed (a late relay + // adoption can mint one without changing the host record; a forget drops one). + val secretsRevision by graph.secure.revision.collectAsState() + var removing by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("nodeterm") }, + actions = { + // A computer with no pairing code (a Server Edition, an SSH-only host): audit A27. + if (hosts.isNotEmpty()) TextButton(onClick = { nav.push(Route.AddSshHost) }) { Text("Add SSH server") } + IconButton(onClick = { nav.push(Route.Settings) }) { Icon(Icons.Filled.Settings, "Settings") } + } + ) + }, + floatingActionButton = { + if (hosts.isNotEmpty()) { + ExtendedFloatingActionButton( + onClick = { nav.push(Route.PairHost()) }, + icon = { Icon(Icons.Filled.Add, null) }, + text = { Text("Pair a computer") } + ) + } + } + ) { padding -> + if (hosts.isEmpty()) { + Column( + Modifier.fillMaxSize().padding(padding).padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Your terminals, in your pocket", style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + Spacer(Modifier.padding(8.dp)) + Text( + "Pair this phone with nodeterm on your computer to watch your agents, answer their questions and " + + "open any terminal on the canvas — on your network, or from anywhere through the end-to-end " + + "encrypted relay.", + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.padding(12.dp)) + Button(onClick = { nav.push(Route.PairHost()) }) { Text("Pair a computer") } + Spacer(Modifier.padding(8.dp)) + Text( + "No pairing code? A nodeterm Server Edition, or a computer you reach over SSH, is added by its address.", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton(onClick = { nav.push(Route.AddSshHost) }) { Text("Add SSH server") } + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = androidx.compose.foundation.layout.PaddingValues(12.dp)) { + // Every computer's Inbox and Usage on one screen (audit A55), once a second one is paired. + if (AllComputers.offered(hosts.size)) { + item(key = "all-computers") { + // From what each session already holds: listing the computers list dials nothing. + val total = rememberListings(hosts).sumOf { AllComputers.needsYou(it.snapshot) } + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .clickable { nav.push(Route.AllComputers) } + ) { + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("All computers", style = MaterialTheme.typography.titleMedium) + Text( + "Inbox and usage from every computer", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + NeedsYouBadge(total) + } + } + } + } + items(hosts, key = { it.id }) { host -> + val session = graph.connections.session(host.id) + val state by session.state.collectAsState() + // The needs-you count on the row (audit A55): from the listing this session already + // holds (an open screen's, the background check's), never a dial of its own. + val snapshot by session.snapshot.collectAsState() + val needsYou = AllComputers.needsYou(snapshot) + // Whether a token is STORED, never its value: decrypting it here was a Keystore + // round trip on the main thread per row per recomposition, and it waited on + // SecureStore's lock behind any connection decrypting meanwhile (audit A47). + val relayTokenStored = remember(host.id, secretsRevision) { graph.secure.hasRelayToken(host.id) } + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .combinedClickable( + onClick = { nav.push(Route.Host(host.id)) }, + onLongClick = { removing = host } + ) + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + ColorDot(stateColor(state)) + Spacer(Modifier.width(10.dp)) + Text(host.name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + NeedsYouBadge(needsYou) + } + Text( + "${host.user}@${host.host}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + routeSummary(host, relayTokenStored) + + stateSuffix(state, host), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + + removing?.let { host -> + AlertDialog( + onDismissRequest = { removing = null }, + title = { Text("Forget ${host.name}?") }, + text = { + Text( + if (host.manual) { + // Added by address (audit A27): no Paired devices entry on the computer, only a key line. + "This phone forgets this computer and its pinned host key. " + ManualHost.revokeHint(host.user) + } else { + "This phone forgets the pairing and its keys for this computer. To revoke the phone's access on " + + "the computer too, remove it under nodeterm → Settings → Phone → Paired devices." + } + ) + }, + confirmButton = { + TextButton(onClick = { + graph.connections.retireAndPublish(listOf(host.id)) { + graph.hosts.remove(host.id) { graph.secure.remove(SecureStore.relayTokenKey(host.id)) } + } + removing = null + }) { Text("Forget") } + }, + dismissButton = { TextButton(onClick = { removing = null }) { Text("Cancel") } } + ) + } +} + +/** "2 need you", in the NEEDS YOU badge's colours; nothing for 0. */ +@Composable +private fun NeedsYouBadge(count: Int) { + if (count <= 0) return + Text( + if (count == 1) "1 needs you" else "$count need you", + color = NtColors.attention, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(NtColors.attention.copy(alpha = 0.16f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) +} + +fun routeSummary(host: PairedHost, hasRelayToken: Boolean): String { + // Added by its SSH address (audit A27): SSH is its only route, and it has no relay to add. + if (host.manual) return "Over SSH only" + val legs = buildList { + if (host.sshAvailable) add(host.sshLegName) + if (host.relay != null && hasRelayToken) add("From anywhere") + } + return if (legs.isEmpty()) "No route configured" else legs.joinToString(" · ") +} + +private fun stateSuffix(state: ConnState, host: PairedHost): String = when (state) { + is ConnState.Connected -> if (state.kind == TransportKind.SSH) { + if (host.manual) " — connected (SSH)" else " — connected (network)" + } else " — connected (relay)" + is ConnState.Connecting -> " — connecting…" + is ConnState.AwaitingApproval -> " — waiting for approval" + is ConnState.Failed -> " — offline" + ConnState.Idle -> "" +} + +fun stateColor(state: ConnState) = when (state) { + is ConnState.Connected -> NtColors.success + is ConnState.Connecting, is ConnState.AwaitingApproval -> NtColors.warning + is ConnState.Failed -> NtColors.attention + ConnState.Idle -> NtColors.muted +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt new file mode 100644 index 0000000000..79b700ca03 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt @@ -0,0 +1,528 @@ +package dev.nodeterm.android.ui + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.RadioButton +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleStartEffect +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.Route +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.NeedsRelayException +import dev.nodeterm.protocol.host.QuickActions +import dev.nodeterm.protocol.model.AccountNames +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.ComputerLabel +import dev.nodeterm.protocol.model.ComputerListing +import dev.nodeterm.protocol.model.ContextFill +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxFeed +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.QuestionChoices +import dev.nodeterm.protocol.model.UsageAccount +import dev.nodeterm.protocol.model.UsageLimit +import dev.nodeterm.protocol.model.UsagePace +import dev.nodeterm.protocol.ssh.NothingFoundException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * The Agents feed (docs/mobile-usage-inbox.md): unresolved approvals and questions on top — with + * deterministic Approve/Deny for a held hook-reply approval — then live activity, then the archive. + * Read/unread is phone-local, like iOS; `resolved` from the computer only moves cards out of the + * actionable list. This is one computer's; the All computers screen merges every computer's through + * the same [InboxFeed] and [InboxFeedList] (audit A55). + */ +@Composable +fun InboxTab(nav: Navigator, hostId: String, session: HostSession, snapshot: ProjectsSnapshot) { + val graph = NodetermApp.graph(LocalContext.current) + // While this tab is on screen, the live refresh announces none of this computer's events; it + // records them as seen, so the background check does not announce them later either (A73). + LifecycleStartEffect(hostId) { + val showing = session.onScreen.showInbox() + onStopOrDispose { showing.close() } + } + val name = graph.hosts.get(hostId)?.name ?: "Computer" + val feed = InboxFeed.of(listOf(ComputerListing(ComputerLabel(hostId, name), snapshot))) + InboxFeedList(nav, feed, showComputer = false, emptyText = "Nothing needs you right now.") +} + +/** What a feed card calls its session: the agent's session name, else the node's title, else "Session". */ +internal fun feedTitle(snapshot: ProjectsSnapshot, nodeId: String): String = + snapshot.findNode(nodeId)?.second?.let { displayTitle(it, snapshot) } ?: snapshot.statusOf(nodeId)?.name ?: "Session" + +/** + * The cards of [feed]: one computer's Inbox tab, or every computer's merged ([showComputer]: each card + * then names its computer). Every card acts on ITS OWN computer (audit A55): Open opens the session on + * the computer the card came from, and an answer goes through that computer's session, since + * [QuickActions] answers over the one connection it is handed, and a merged feed holds cards of + * several computers. + */ +@Composable +internal fun InboxFeedList(nav: Navigator, feed: InboxFeed, showComputer: Boolean, emptyText: String) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val scope = rememberCoroutineScope() + // Saveable, so an open archive stays open after a terminal opened from it (audit A43). + var showArchive by rememberSaveable { mutableStateOf(false) } + var pendingActions by remember { mutableStateOf(emptySet>()) } + + // Each card under ITS computer: a merged feed holds several, and two can mint the same event id. + LaunchedEffect(feed.actionable.map { it.key }) { + feed.actionable.groupBy({ it.hostId }, { it.event }).forEach { (hostId, events) -> graph.hosts.markSeen(hostId, events) } + } + + fun openOn(from: ComputerListing, nodeId: String) = + nav.push(Route.Terminal(from.computer.hostId, nodeId, feedTitle(from.snapshot, nodeId))) + + fun runOn(from: ComputerListing, label: String, block: suspend (HostConnection) -> QuickActions.Result, nodeId: String, eventId: String) { + val actionKey = from.computer.hostId to eventId + if (actionKey in pendingActions) return + pendingActions = pendingActions + actionKey + // The card's own computer, whichever screen shows it. + val session = graph.connections.session(from.computer.hostId) + scope.launch { + try { + val result = try { + block(session.ensureConnected()) + } catch (e: NeedsRelayException) { + // Direct SSH reaches only this computer; a node of one of its SSH projects is + // answered where it lives, through the relay (audit A09). With no relay leg to + // open, say what is in the way for this computer instead (A27 and its review). + e.refusal(session.relayLeg())?.let { throw HostException(it) } + block(session.viaRelay()) + } + when (result) { + QuickActions.Result.SENT -> Toast.makeText(context, label, Toast.LENGTH_SHORT).show() + QuickActions.Result.ALREADY_HANDLED -> Toast.makeText(context, "Already handled.", Toast.LENGTH_SHORT).show() + QuickActions.Result.OPEN_SESSION -> openOn(from, nodeId) + QuickActions.Result.EXPIRED -> { + Toast.makeText(context, "The request timed out on the computer. Answer it in the session.", Toast.LENGTH_LONG).show() + openOn(from, nodeId) + } + } + session.refreshNow() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val text = (e as? NothingFoundException)?.said(session.relayLeg()) ?: e.message ?: "Couldn't reach ${from.computer.label}." + Toast.makeText(context, text, Toast.LENGTH_LONG).show() + } finally { + pendingActions = pendingActions - actionKey + } + } + } + + if (feed.isEmpty) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text(emptyText, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + return + } + + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(feed.actionable, key = { "a-${it.key}" }) { item -> + val ev = item.event + EventCard( + ev, feedTitle(item.from.snapshot, ev.nodeId), item.from.snapshot, item.contextPercent, + computer = item.from.computer.label.takeIf { showComputer }, + highlight = true, + onOpen = { openOn(item.from, ev.nodeId) } + ) { + EventActions( + ev, + busy = (item.from.computer.hostId to ev.id) in pendingActions, + open = { nodeId -> openOn(item.from, nodeId) }, + run = { label, block, nodeId -> runOn(item.from, label, block, nodeId, ev.id) } + ) + } + } + items(feed.working, key = { "w-${it.key}" }) { live -> + val now = live.now + val nodeId = live.nodeId + Column( + Modifier.fillMaxWidth().background(NtColors.panel, RoundedCornerShape(10.dp)).clickable { openOn(live.from, nodeId) }.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (showComputer) ComputerLine(live.from.computer.label) + Row(verticalAlignment = Alignment.CenterVertically) { + StatusBadge(dev.nodeterm.protocol.model.SessionBucket.RUNNING) + Spacer(Modifier.width(8.dp)) + Text(feedTitle(live.from.snapshot, nodeId), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + now.prompt?.let { Text("You: $it", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) } + now.activity?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + ContextIndicator(now.contextPercent) + } + } + if (feed.archived.isNotEmpty()) { + item(key = "archive-toggle") { + TextButton(onClick = { showArchive = !showArchive }) { + Text(if (showArchive) "Hide archive" else "Archive (${feed.archived.size})") + } + } + if (showArchive) { + items(feed.archived, key = { "r-${it.key}" }) { item -> + val ev = item.event + EventCard( + ev, feedTitle(item.from.snapshot, ev.nodeId), item.from.snapshot, item.contextPercent, + computer = item.from.computer.label.takeIf { showComputer }, + highlight = false, + onOpen = { openOn(item.from, ev.nodeId) } + ) {} + } + } + } + } +} + +/** + * What an open card offers. [open] and [run] are bound to the card's own computer by [InboxFeedList]. + */ +@Composable +private fun EventActions( + ev: InboxEvent, + busy: Boolean, + open: (nodeId: String) -> Unit, + run: (label: String, block: suspend (HostConnection) -> QuickActions.Result, nodeId: String) -> Unit +) { + if (ev.kind == InboxKind.APPROVAL) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(enabled = !busy, onClick = { + run("Approved.", { c -> QuickActions.answerApproval(c, ev, allow = true) }, ev.nodeId) + }) { Text("Approve") } + OutlinedButton(enabled = !busy, onClick = { + run("Denied.", { c -> QuickActions.answerApproval(c, ev, allow = false) }, ev.nodeId) + }) { Text("Deny") } + TextButton(onClick = { open(ev.nodeId) }) { Text("Open") } + } + if (ev.pendingId != null && ev.permissionSuggestions.isNotEmpty()) { + var rememberDialog by remember(ev.id, ev.pendingId) { mutableStateOf(false) } + var selectedRule by remember(ev.id, ev.pendingId) { mutableStateOf(null) } + TextButton(enabled = !busy, onClick = { rememberDialog = true }) { Text("Always allow…") } + if (rememberDialog) AlertDialog( + onDismissRequest = { rememberDialog = false }, + title = { Text("Allow and remember a rule") }, + text = { + Column(Modifier.heightIn(max = 360.dp).verticalScroll(rememberScrollState())) { + Text("Choose the rule and where it applies. This does not change the session's permission mode.") + ev.permissionSuggestions.forEach { suggestion -> + Row(Modifier.fillMaxWidth().clickable { selectedRule = suggestion.index }, verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = selectedRule == suggestion.index, onClick = { selectedRule = suggestion.index }) + Text(suggestion.label) + } + } + } + }, + confirmButton = { + TextButton(enabled = !busy && selectedRule != null, onClick = { + val index = selectedRule ?: return@TextButton + rememberDialog = false + run("Approval sent.", { c -> QuickActions.rememberApproval(c, ev, index) }, ev.nodeId) + }) { Text("Allow and remember") } + }, + dismissButton = { TextButton(onClick = { rememberDialog = false }) { Text("Cancel") } } + ) + } + } else { + // One rule for what the card offers and what the answer path accepts (audit A57). + when (val choices = QuestionChoices.of(ev)) { + is QuestionChoices.Held -> { + var selected by remember(ev.id, choices.ticket) { mutableStateOf(List(choices.questions.size) { emptySet() }) } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + choices.questions.forEachIndexed { qi, question -> + Text(question.question, fontWeight = FontWeight.SemiBold) + if (question.multiSelect) Text("Choose one or more", style = MaterialTheme.typography.labelSmall) + question.options.forEachIndexed { oi, option -> + fun toggle() { + selected = selected.mapIndexed { i, set -> + if (i != qi) set else if (!question.multiSelect) setOf(oi) + else if (oi in set) set - oi else set + oi + } + } + Row(Modifier.fillMaxWidth().clickable { toggle() }, verticalAlignment = Alignment.CenterVertically) { + if (question.multiSelect) Checkbox(checked = oi in selected[qi], onCheckedChange = { toggle() }) + else RadioButton(selected = oi in selected[qi], onClick = { toggle() }) + Column { + Text(option.label) + if (option.description.isNotBlank()) Text(option.description, style = MaterialTheme.typography.bodySmall) + } + } + } + } + Button(enabled = !busy && selected.all { it.isNotEmpty() }, onClick = { + val answer = selected.map { it.sorted() } + run("Answered.", { c -> QuickActions.answerQuestions(c, ev, answer) }, ev.nodeId) + }) { Text("Send answers") } + TextButton(onClick = { open(ev.nodeId) }) { Text("Open session") } + } + } + is QuestionChoices.Answer -> Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + choices.rows.forEachIndexed { i, row -> + OutlinedButton(enabled = !busy, onClick = { + run("Answered.", { c -> QuickActions.answerQuestion(c, ev, i) }, ev.nodeId) + }, modifier = Modifier.fillMaxWidth()) { Text(row, maxLines = 2) } + } + } + // Multi-select: shown so the card says what is asked, but plain text, not + // buttons. The picker's toggle/submit keys are unmeasured, so it is answered + // in the session (QuestionChoices says why). + is QuestionChoices.ReadOnly -> Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + QuestionChoices.SEVERAL_NOTE, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + choices.rows.forEach { row -> + Text( + row, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp) + ) + } + TextButton(onClick = { open(ev.nodeId) }) { Text("Open session") } + } + QuestionChoices.None -> TextButton(onClick = { open(ev.nodeId) }) { Text("Open session") } + } + } +} + +/** The computer a card on a merged screen came from (audit A55). */ +@Composable +private fun ComputerLine(label: String) { + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) +} + +@Composable +private fun EventCard( + ev: InboxEvent, + title: String, + snapshot: ProjectsSnapshot, + /** The node's context-window fill (`inbox.nodes[nodeId].contextPercent`), when known. */ + contextPercent: Double?, + /** The card's computer, on a merged screen (audit A55); null on a computer's own Inbox. */ + computer: String?, + highlight: Boolean, + onOpen: () -> Unit, + actions: @Composable () -> Unit +) { + val accent = when (ev.kind) { + InboxKind.APPROVAL -> NtColors.attention + InboxKind.QUESTION -> NtColors.warning + InboxKind.DONE -> NtColors.success + } + Column( + Modifier + .fillMaxWidth() + .background(if (highlight) accent.copy(alpha = 0.10f) else NtColors.panel, RoundedCornerShape(10.dp)) + .clickable(onClick = onOpen) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + computer?.let { ComputerLine(it) } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + when (ev.kind) { + InboxKind.APPROVAL -> "NEEDS APPROVAL" + InboxKind.QUESTION -> "QUESTION" + InboxKind.DONE -> if (ev.interrupted) "INTERRUPTED" else "FINISHED" + }, + color = accent, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + Spacer(Modifier.width(8.dp)) + Text(title, style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Text(relativeAge(ev.ts), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text(ev.title, fontWeight = FontWeight.SemiBold) + ev.detail?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 4) } + val agent = Agent.of(ev.agentId ?: snapshot.statusOf(ev.nodeId)?.agentId) + if (agent != null || ContextFill.percent(contextPercent) != null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + agent?.let { Text(it.label, style = MaterialTheme.typography.labelSmall, color = parseHex(it.color)) } + ContextIndicator(contextPercent) + } + } + actions() + } +} + +/** + * A node's context-window fill as a small ring and "42% context" (docs/mobile-usage-inbox.md: cards + * show the node's `contextPercent` ring when known). Nothing at all when it is unknown. The ring's + * colour follows the desktop context meter's bands ([ContextFill.level]). + */ +@Composable +private fun ContextIndicator(contextPercent: Double?) { + val raw = contextPercent ?: return + val pct = ContextFill.percent(raw) ?: return + val label = ContextFill.label(raw) ?: return + val color = when (ContextFill.level(raw)) { + ContextFill.Level.CRITICAL -> NtColors.attention + ContextFill.Level.HIGH -> NtColors.warning + ContextFill.Level.OK -> NtColors.success + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + CircularProgressIndicator( + progress = { pct / 100f }, + modifier = Modifier.size(12.dp), + color = color, + strokeWidth = 2.dp, + trackColor = NtColors.panel2 + ) + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** + * Usages (docs/mobile-usage-inbox.md): per account, each limit's consumption and reset. Colour is + * the provider's severity when given, else derived (≥90 red, ≥70 amber, else green). + */ +@Composable +fun UsageTab(snapshot: ProjectsSnapshot) { + val usage = snapshot.status?.usage + if (usage == null || usage.accounts.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + "This computer isn't reporting usage. (Usage is shared by nodeterm on the computer for its local accounts.)", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + return + } + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(usage.accounts, key = { it.accountId ?: "system" }) { account -> UsageCard(account, snapshot.status) } + updatedAgo(usage.updatedAt)?.let { item { Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } + } +} + +/** "Updated 5m ago" for a usage snapshot taken at [ts]; "Updated just now" for a fresh one; null for no time. */ +internal fun updatedAgo(ts: Long, now: Long = System.currentTimeMillis()): String? = when (val age = relativeAge(ts, now)) { + "" -> null + "now" -> "Updated just now" + else -> "Updated $age ago" +} + +/** One account's usage. Also drawn, per computer, by the All computers screen (audit A55). */ +@Composable +internal fun UsageCard(account: UsageAccount, status: AgentStatusFile?) { + // The same resolver the sessions list uses, so a managed account is never titled by its UUID. + val title = account.accountId?.let { AccountNames.managed(it, status) } + ?: account.label ?: account.email ?: AccountNames.SYSTEM + Column( + Modifier.fillMaxWidth().background(NtColors.panel, RoundedCornerShape(10.dp)).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(title, fontWeight = FontWeight.SemiBold) + account.email?.takeIf { it != title }?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + if (account.status != "ok" && account.limits.isEmpty()) { + Text(if (account.status == "error") "Could not read usage." else "Usage unavailable.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + account.limits.forEach { UsageBar(it, measuredAt = account.updatedAt) } + } +} + +@Composable +private fun UsageBar(limit: UsageLimit, measuredAt: Long, now: Long = System.currentTimeMillis()) { + val pct = limit.usedPercent.coerceIn(0.0, 100.0) + val color: Color = when (limit.severity) { + "critical", "error", "red" -> NtColors.attention + "warning", "amber", "yellow" -> NtColors.warning + "ok", "normal", "green" -> NtColors.success + else -> if (pct >= 90) NtColors.attention else if (pct >= 70) NtColors.warning else NtColors.success + } + val name = when (limit.kind) { + "session" -> "Session window" + "weekly_all" -> "Weekly (all models)" + "weekly_scoped" -> "Weekly · ${limit.scopeLabel ?: "model"}" + else -> limit.scopeLabel ?: limit.kind + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row { + Text(name, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + Text("${pct.toInt()}%" + (limit.resetsAt?.let { " · resets ${resetLabel(it, now)}" } ?: ""), style = MaterialTheme.typography.bodySmall) + } + LinearProgressIndicator( + progress = { (pct / 100.0).toFloat() }, + color = color, + trackColor = NtColors.panel2, + modifier = Modifier.fillMaxWidth().height(6.dp) + ) + // Only when the window's end and length are known (UsagePace says why it would refuse), + // judged at the time the percentage was measured, not now (UsagePace says why). + UsagePace.of(limit, measuredAt = measuredAt, now = now)?.let { reading -> + Text( + reading.line, + style = MaterialTheme.typography.labelSmall, + color = if (reading.pace == UsagePace.Pace.FASTER) NtColors.warning else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** `<24h → "16:40"` clock time, else `"3d 16h"` — the iOS usage tab's reset label. */ +private fun resetLabel(resetsAt: Long, now: Long = System.currentTimeMillis()): String { + val left = resetsAt - now + if (left <= 0) return "now" + return if (left < 86_400_000L) SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(resetsAt)) + else "${left / 86_400_000L}d ${(left % 86_400_000L) / 3_600_000L}h" +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/Insets.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/Insets.kt new file mode 100644 index 0000000000..f4adc86eb7 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/Insets.kt @@ -0,0 +1,35 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier + +/** + * The body of a Scaffold that holds a text field: the Scaffold's padding, plus room for the soft + * keyboard (audit A77). The one place the app asks for the keyboard's inset. + * + * The app targets API 35, so on Android 15 its window is edge-to-edge whether it asks or not: the + * window is no longer resized for the keyboard (`adjustResize` stops doing it) and the system bars' + * and the keyboard's insets reach Compose instead. The Scaffold's padding already holds the + * navigation bar, and the keyboard's inset is measured from the bottom of the screen, navigation bar + * included, so a bare `imePadding()` after it counted the navigation bar twice. Consuming the padding + * first leaves `imePadding()` only what the keyboard covers beyond it. + * + * On API 26–34 the window is not edge-to-edge (the app does not call `enableEdgeToEdge()`, on + * purpose: every screen would then depend on its inset handling there too). The window's decor + * consumes those insets before they reach Compose and makes room for the keyboard itself + * (`adjustResize`), so this is the Scaffold's padding alone. + * + * Apply it before a `verticalScroll`: the keyboard then shrinks the scrolled viewport, which Compose + * keeps the focused field inside, instead of padding the content's end. + * + * Text fields in an `AlertDialog` do not go through here. A dialog is its own window, and a floating + * one (the theme's `dialogTheme`): the framework drops inset fitting only for a non-floating window, + * so a dialog stays framed by the system and does not rely on Compose's insets. An `imePadding()` + * inside one could add the keyboard a second time. How a dialog sits over the keyboard on Android 15 + * has not been checked on a device. + */ +fun Modifier.aboveKeyboard(scaffoldPadding: PaddingValues): Modifier = + padding(scaffoldPadding).consumeWindowInsets(scaffoldPadding).imePadding() diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/Overlays.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/Overlays.kt new file mode 100644 index 0000000000..0a36c03ffa --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/Overlays.kt @@ -0,0 +1,29 @@ +package dev.nodeterm.android.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput + +/** + * Keeps a touch on an overlay from reaching what the overlay is drawn over (review of audit A32). + * Every bar, card and sheet laid over the terminal carries it, on its root and before its padding, + * so the whole drawn area counts. + * + * Compose hit-tests overlapping siblings from the top down, and where the top one has no + * pointer-input node under the touch it moves on to the next sibling. `background()` and `Text` are + * not pointer-input nodes, so only an overlay's buttons and lists stopped a touch: a tap on the link + * bar's URL, or on the Copy sheet's title, reached the terminal's WebView below it (a tmux click or + * wheel scroll into the live pane, or a link offer that then replaced the one on screen), and a tap + * on the sheet's bottom row reached the input bar, whose focus raised the soft keyboard over the sheet. + * + * Any pointer-input node ends that search, so this one only has to be there: it reads every event and + * consumes none. Consuming here would break the overlay itself. A parent sees the Main pass after its + * children, and a child's tap and drag detection look at the Final pass for a consumed change (Compose + * foundation's `waitForUpOrCancellation` and its drag slop do): a consumed move, which any finger's + * jitter produces between down and up, would cancel a tap on the overlay's own buttons and the scroll + * of its list. + */ +fun Modifier.blockTouchesBelow(): Modifier = pointerInput(Unit) { + awaitPointerEventScope { + while (true) awaitPointerEvent() + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt new file mode 100644 index 0000000000..7aadd4ea96 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt @@ -0,0 +1,203 @@ +package dev.nodeterm.android.ui + +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.Route +import dev.nodeterm.android.data.SecureStore +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.PairedHostReplacement +import dev.nodeterm.protocol.pairing.PairingClient +import dev.nodeterm.protocol.pairing.PairingException +import dev.nodeterm.protocol.pairing.PairingPayload +import kotlinx.coroutines.launch + +/** + * The phone side of Settings → Phone → "Pair a phone" on the desktop. Scanning the QR (or pasting + * its text) gives a one-time token; the phone answers with its Ed25519 public key and its own id, + * sealed to the host key the QR carries, and gets back a device id plus — when remote access is on + * — the relay leg. Nothing secret leaves the phone. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PairScreen(nav: Navigator, initialCode: String? = null) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val scope = rememberCoroutineScope() + var raw by remember { mutableStateOf("") } + var payload by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + fun accept(text: String) { + val p = PairingPayload.parse(text) + if (p == null) { + error = "That isn't a nodeterm pairing code. On your computer open nodeterm → Settings → Phone → Pair a phone." + } else { + error = null + payload = p + } + } + + LaunchedEffect(initialCode) { initialCode?.let { accept(it) } } + + val scanner = rememberLauncherForActivityResult(ScanContract()) { result -> + result.contents?.let { accept(it) } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Pair a computer") }, + navigationIcon = { IconButton(onClick = { nav.pop() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } } + ) + } + ) { padding -> + Column( + Modifier.fillMaxSize().aboveKeyboard(padding).padding(20.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + val p = payload + if (p == null) { + Text("1. On your computer, open nodeterm → Settings → Phone and choose Pair a phone.") + Text("2. Scan the code it shows. Your phone must be on the same network as the computer for this one step.") + Button(onClick = { + scanner.launch( + ScanOptions() + .setDesiredBarcodeFormats(ScanOptions.QR_CODE) + .setPrompt("Scan the pairing code on your computer") + .setBeepEnabled(false) + .setOrientationLocked(false) + ) + }, modifier = Modifier.fillMaxWidth()) { Text("Scan the pairing code") } + Text("Or paste the code's text:", color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField( + value = raw, + onValueChange = { raw = it }, + modifier = Modifier.fillMaxWidth().height(120.dp), + placeholder = { Text("{\"v\":1,\"host\":…}") } + ) + OutlinedButton(onClick = { accept(raw) }, enabled = raw.isNotBlank()) { Text("Use this code") } + // No code to scan: a Server Edition has no pairing service, and an SSH-only host no + // nodeterm to show one (audit A27). + Text( + "No pairing code? A nodeterm Server Edition, or a computer you reach over SSH, is added by its address.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton(onClick = { nav.push(Route.AddSshHost) }) { Text("Add SSH server") } + } else { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(p.name, style = MaterialTheme.typography.titleLarge) + Text("${p.user}@${p.host}", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(if (p.hostKey != null) "✓ The pairing exchange is end-to-end encrypted" else "⚠ This computer's nodeterm is too old to encrypt pairing") + Text(if (p.sshAvailable) "✓ Direct connection on your network (SSH)" else "Relay-only computer (no SSH)") + // Not "reachable from anywhere": that is only true once the computer has + // approved this phone for the relay (at the scan on a current desktop, or on + // the first relay connect on an older one) — audit A07. + Text(if (p.relay != null) "Remote access is on" else "Remote access is off — this phone will only reach the computer on your network") + } + } + if (!p.sshAvailable && p.relay == null) { + Text( + "This computer can only be reached through remote access, which is off. Turn it on in nodeterm → Settings → Phone and scan the new code.", + color = NtColors.warning + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Button(enabled = !busy, onClick = { + busy = true + error = null + scope.launch { + try { + // The backend's free-tier re-registration wants the phone's PREVIOUS device + // token for this computer (pairing-service.ts `priorDeviceToken`). + val previous = graph.hosts.hosts.value.firstOrNull { it.hostKeyB64 != null && it.hostKeyB64 == p.hostKey } + val prior = previous?.let { graph.secure.getString(SecureStore.relayTokenKey(it.id)) } + val result = PairingClient().pair( + payload = p, + sshPublicKeyLine = graph.sshIdentity.authorizedKeysLine("nodeterm-android"), + deviceName = graph.hosts.deviceName, + deviceId = graph.identity.deviceId(), + priorDeviceToken = prior, + boxPublicKeyB64 = graph.boxKeys.publicKeyB64 + ) + val host = PairedHost.from(p, result) + // Block factory admission until retirement and local publication + // finish. Always retire the incoming id, including a changed box key. + val retiredIds = PairedHostReplacement.retired(graph.hosts.hosts.value, host, previous).map { it.id } + host.id + graph.connections.retireAndPublish(retiredIds) { + graph.hosts.publishPairing(host, previous, + saveToken = { result.relayDeviceToken?.let { graph.secure.putString(SecureStore.relayTokenKey(host.id), it) } }, + removeToken = { id -> graph.secure.remove(SecureStore.relayTokenKey(id)) }) + } + // Also with no relay leg yet (A07-late): the computer approves this phone's + // key on its first relay connect, so once a late adoption gives it a leg the + // background check may use it without raising a dialog. + if (result.relayApproved) graph.hosts.setRelayApproved(host.id, true) + Toast.makeText(context, result.pairedNotice(), Toast.LENGTH_LONG).show() + nav.replaceAll(Route.Hosts) + nav.push(Route.Host(host.id)) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + // Always a sentence: a bare exception message can be a lone token + // (audit A54 saw "-1"). + error = PairingException.userMessage(e) + } finally { + busy = false + } + } + }) { Text("Pair") } + Spacer(Modifier.size(12.dp)) + OutlinedButton(onClick = { payload = null }, enabled = !busy) { Text("Scan again") } + if (busy) { + Spacer(Modifier.size(12.dp)) + CircularProgressIndicator(Modifier.size(24.dp)) + } + } + } + error?.let { Text(it, color = NtColors.attention) } + } + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt new file mode 100644 index 0000000000..1c1673d7b9 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt @@ -0,0 +1,490 @@ +package dev.nodeterm.android.ui + +import android.widget.Toast +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.Route +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.host.NeedsRelayException +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.AccountNames +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.ContextFill +import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.NewSessionChoice +import dev.nodeterm.protocol.host.ManagedSessionChoice +import dev.nodeterm.protocol.host.ManagedSessionCreation +import dev.nodeterm.protocol.model.NodeInfo +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.SessionBucket +import dev.nodeterm.protocol.model.SessionSearch +import dev.nodeterm.protocol.ssh.PhoneTerminals +import dev.nodeterm.protocol.ssh.SshHostConnection +import kotlinx.coroutines.launch + +/** A phone-started session waiting for its terminal screen to type the launch line and register it. */ +data class LaunchRequest( + val nodeId: String, + val projectId: String, + val title: String, + val agentId: String?, + val accountId: String?, + val command: String? +) + +/** Hand-off from the New-session dialog to the terminal screen that performs the launch. */ +object PendingLaunches { + private val byNode = HashMap() + + @Synchronized + fun put(r: LaunchRequest) { + byNode[r.nodeId] = r + } + + @Synchronized + fun take(nodeId: String): LaunchRequest? = byNode.remove(nodeId) + + @Synchronized + fun peek(nodeId: String): LaunchRequest? = byNode[nodeId] +} + +private val BUCKET_ORDER = listOf(SessionBucket.NEEDS_YOU, SessionBucket.RUNNING, SessionBucket.SLEEPING, SessionBucket.UNKNOWN) + +private fun bucketTitle(b: SessionBucket) = when (b) { + SessionBucket.NEEDS_YOU -> "Waiting for your response" + SessionBucket.RUNNING -> "Running" + SessionBucket.SLEEPING -> "Sleeping" + SessionBucket.UNKNOWN -> "Other sessions" +} + +/** What a session row is called: the agent's own session name, else the node title, else the agent. */ +fun displayTitle(node: NodeInfo, snapshot: ProjectsSnapshot): String = + SessionSearch.title(node, snapshot) + +/** + * [newSessionNote] is why New session is unavailable (audit A26), shown as the list's first row while + * the button below stays disabled: a box of its own beside the button covered the last sessions. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SessionsTab(nav: Navigator, hostId: String, session: HostSession, snapshot: ProjectsSnapshot, newSessionNote: String? = null) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var menuFor by remember { mutableStateOf(null) } + var renaming by remember { mutableStateOf(null) } + var ending by remember { mutableStateOf(null) } + // Stored by the host/tab's A43 saved-state holders, including a terminal pushed above this page. + var query by rememberSaveable(hostId) { mutableStateOf("") } + + fun act(label: String, block: suspend () -> Unit) { + scope.launch { + try { + block() + session.refreshNow() + } catch (e: Exception) { + Toast.makeText(context, "$label: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + + val projects = SessionSearch.projects(snapshot, query) + Column(Modifier.fillMaxSize()) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text("Search sessions") }, + singleLine = true, + trailingIcon = { + if (query.isNotEmpty()) TextButton(onClick = { query = "" }) { Text("Clear search") } + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp) + ) + if (projects.isEmpty()) { + newSessionNote?.let { NewSessionNote(it) } + Box(Modifier.weight(1f).fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + when (SessionSearch.emptyState(snapshot, query)) { + SessionSearch.EmptyState.LOADING -> "Loading sessions…" + SessionSearch.EmptyState.EMPTY -> "No sessions on this computer yet." + SessionSearch.EmptyState.NO_MATCHES -> "No sessions match your search." + }, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn(Modifier.weight(1f).fillMaxWidth(), contentPadding = PaddingValues(bottom = 96.dp)) { + if (newSessionNote != null) item(key = "new-session-note") { NewSessionNote(newSessionNote) } + for (project in projects) { + item(key = "p-${project.id}") { + ProjectHeader(project, onSourceControl = { nav.push(Route.SourceControl(hostId, project.id)) }) + } + val grouped = project.sessions.groupBy { snapshot.statusOf(it.id)?.bucket ?: SessionBucket.UNKNOWN } + for (bucket in BUCKET_ORDER) { + val rows = grouped[bucket].orEmpty().sortedByDescending { snapshot.statusOf(it.id)?.updatedAt ?: 0 } + if (rows.isEmpty()) continue + item(key = "b-${project.id}-$bucket") { + Text( + bucketTitle(bucket).uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 10.dp, bottom = 4.dp) + ) + } + items(rows, key = { "n-${project.id}-${it.id}" }) { node -> + Box { + SessionRow( + node, snapshot, + onClick = { nav.push(Route.Terminal(hostId, node.id, displayTitle(node, snapshot))) }, + onLongClick = { menuFor = node } + ) + DropdownMenu(expanded = menuFor?.id == node.id, onDismissRequest = { menuFor = null }) { + DropdownMenuItem(text = { Text("Open") }, onClick = { + menuFor = null + nav.push(Route.Terminal(hostId, node.id, displayTitle(node, snapshot))) + }) + // Wake/refresh/rename are nodeterm the app's (`node.*`): on the LAN that is + // the relay leg opened next to SSH, on a tap. Where this phone has none they + // stay listed, disabled, with the reason (audit A26) — and so they do for a + // project another desktop drives over SSH, whose app is that desktop (A27). + if (!PhoneTerminals.validId(node.id)) { + val blocked = session.route(Capability.NODE_ACTIONS, project) as? LegRouting.Leg.Unavailable + if (snapshot.statusOf(node.id)?.hibernated == true) { + DropdownMenuItem(text = { Text("Wake") }, enabled = blocked == null, onClick = { + menuFor = null + act("Wake") { session.connectionFor(Capability.NODE_ACTIONS, project = project).wake(node.id) } + }) + } + DropdownMenuItem(text = { Text("Refresh view on computer") }, enabled = blocked == null, onClick = { + menuFor = null + act("Refresh") { session.connectionFor(Capability.NODE_ACTIONS, project = project).refresh(node.id) } + }) + DropdownMenuItem(text = { Text("Rename…") }, enabled = blocked == null, onClick = { + menuFor = null + renaming = node + }) + if (blocked != null) { + Text( + blocked.reason, + Modifier.widthIn(max = 280.dp).padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + DropdownMenuItem(text = { Text("End session…", color = NtColors.attention) }, onClick = { + menuFor = null + ending = node + }) + } + } + } + } + } + } + } + } + + renaming?.let { node -> + var title by remember(node.id) { mutableStateOf(displayTitle(node, snapshot)) } + AlertDialog( + onDismissRequest = { renaming = null }, + title = { Text("Rename session") }, + text = { OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true) }, + confirmButton = { + TextButton(enabled = title.isNotBlank(), onClick = { + renaming = null + act("Rename") { + session.connectionFor(Capability.NODE_ACTIONS, project = snapshot.findNode(node.id)?.first).rename(node.id, title.trim()) + } + }) { Text("Rename") } + }, + dismissButton = { TextButton(onClick = { renaming = null }) { Text("Cancel") } } + ) + } + + ending?.let { node -> + val conn = session.connection + val viaSsh = conn?.kind == TransportKind.SSH + AlertDialog( + onDismissRequest = { ending = null }, + title = { Text("End ${displayTitle(node, snapshot)}?") }, + text = { + Text( + if (PhoneTerminals.validId(node.id)) "This stops the shell and everything running in it, and removes it from the phone terminals list." + else if (viaSsh) "This stops the session's tmux session on the computer, and everything running in it. " + + "The node stays on the canvas until you remove it there." + else "This permanently ends the session and removes the node from the canvas — like its × on the computer." + ) + }, + confirmButton = { + TextButton(onClick = { + ending = null + val c = conn ?: return@TextButton + act("End session") { + if (PhoneTerminals.validId(node.id) && c !is SshHostConnection) { + throw HostException("Connect over SSH to end this phone terminal.") + } + val quiet = object : TerminalSink { + override fun onPaint(text: String) {} + override fun onOutput(bytes: ByteArray) {} + override fun onExit(code: Int?) {} + } + if (c is SshHostConnection) { + try { + c.killSession(node.id) + } catch (e: NeedsRelayException) { + // A node of an SSH project lives on its host: end it through the + // relay, where the desktop reaches that host (audit A09). With no + // relay leg to open, say what is in the way for this computer (A27). + e.refusal(session.relayLeg())?.let { throw HostException(it) } + session.viaRelay().attach(node.id, 80, 24, quiet).endSession() + } + } else { + c.attach(node.id, 80, 24, quiet).endSession() + } + } + }) { Text("End session", color = NtColors.attention) } + }, + dismissButton = { TextButton(onClick = { ending = null }) { Text("Cancel") } } + ) + } +} + +/** Why New session is disabled, said once at the top of the list (audit A26), like the Board's read-only note. */ +@Composable +private fun NewSessionNote(reason: String) { + Text( + reason, + Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + +/** + * A project's heading, with its Source control (audit A29). The entry is offered for every project: + * the screen itself says why one cannot have it (no folder on the computer, an SSH project, no relay + * leg), rather than a heading that silently lacks it. + */ +@Composable +private fun ProjectHeader(project: ProjectInfo, onSourceControl: () -> Unit) { + Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 18.dp), verticalAlignment = Alignment.CenterVertically) { + ColorDot(parseHex(project.color, NtColors.accent), 12) + Spacer(Modifier.width(10.dp)) + Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Text( + project.name, + Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + project.sshTarget?.let { + Spacer(Modifier.width(8.dp)) + Text("on $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + // Its sessions run on this computer, its canvas is a desktop elsewhere's (A27). + if (project.drivenRemotely) { + Spacer(Modifier.width(8.dp)) + Text( + "run here over SSH", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) + } + } + if (project.id != PhoneTerminals.PROJECT_ID) { + TextButton(onClick = onSourceControl) { Text("Source control") } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SessionRow(node: NodeInfo, snapshot: ProjectsSnapshot, onClick: () -> Unit, onLongClick: () -> Unit) { + val status = snapshot.statusOf(node.id) + val now = snapshot.status?.inbox?.nodes?.get(node.id) + val agent = Agent.of(node.agentId ?: status?.agentId) + val live = snapshot.isLive(node.id) + Row( + Modifier + .fillMaxWidth() + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (node.iconEmoji != null) Text(node.iconEmoji!!) else ColorDot(parseHex(node.color ?: agent?.color, NtColors.muted), 12) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(displayTitle(node, snapshot), maxLines = 1, overflow = TextOverflow.Ellipsis) + val detail = buildList { + add(SessionSearch.agentLabel(node, snapshot)) + if (PhoneTerminals.validId(node.id)) node.cwd?.let { add(it) } + now?.activity?.let { add(it) } + ContextFill.label(now?.contextPercent)?.let { add(it) } + AccountNames.observed(status?.account, snapshot.status)?.let { add(it) } + if (!live) add("not running") + }.joinToString(" · ") + Text( + detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.width(8.dp)) + Column(horizontalAlignment = Alignment.End) { + StatusBadge(status?.bucket) + status?.updatedAt?.let { + Text(relativeAge(it), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +/** New session: a project on THIS computer, an agent (or a plain shell), and for Claude an account. */ +private fun noManagedAction() = Unit + +@Composable +fun NewSessionDialog(snapshot: ProjectsSnapshot, onDismiss: () -> Unit, onCreate: (LaunchRequest) -> Unit, + onManagedCreate: ((ManagedSessionChoice) -> Unit)? = null, + managedState: ManagedSessionCreation.State? = null, + onOpenManaged: () -> Unit = ::noManagedAction, onCheckedManaged: () -> Unit = ::noManagedAction) { + // Only projects the desktop can register a node in: on this computer, with a folder (A14). + val projects = NewSessionChoice.offeredProjects(snapshot) + // The user's taps are remembered, but the listing is re-fetched under the open dialog, so a tap + // can name a project or account the desktop has since removed. What the dialog draws as selected, + // and what Start uses, is derived from the CURRENT listing — the same answer for both (A42). + var projectId by remember { mutableStateOf(projects.firstOrNull()?.id) } + val selected = NewSessionChoice.project(projects, projectId) + var agent by remember { mutableStateOf(Agent.CLAUDE) } + val settings = snapshot.status?.settings + val accounts = settings?.claudeAccounts.orEmpty() + // Starts at the selected project's own default account, when this host still has it (A16), and + // again whenever the selection moves to another project. + var accountPick by remember(selected?.id) { mutableStateOf(Launch.defaultAccount(settings, selected)) } + val accountId = NewSessionChoice.account(settings, selected, accountPick) + val editable = managedState == null || managedState is ManagedSessionCreation.State.Idle || managedState is ManagedSessionCreation.State.Refused + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New session") }, + text = { + LazyColumn(verticalArrangement = Arrangement.spacedBy(2.dp)) { + when (val current = managedState) { + is ManagedSessionCreation.State.Creating -> item { Text("Creating the session on the computer… You can close this dialog; creation will finish there.") } + is ManagedSessionCreation.State.Uncertain -> item { Text(current.message + " Refresh the sessions list or inspect the computer before starting another session.") } + is ManagedSessionCreation.State.Ready -> item { Text("The computer created the session. Open it to view the existing terminal.") } + is ManagedSessionCreation.State.Refused -> item { Text(current.message) } + else -> Unit + } + if (editable) { + item { Text("Project", style = MaterialTheme.typography.labelLarge) } + if (projects.isEmpty()) { + item { + Text( + "No open project on this computer has a folder to start a session in.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + items(projects, key = { "proj-${it.id}" }) { p -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = selected?.id == p.id, onClick = { projectId = p.id }) + Text(p.name) + } + } + item { Text("Run", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp)) } + items(listOf(null) + Agent.entries, key = { "agent-${it?.id ?: "shell"}" }) { a -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = agent == a, onClick = { agent = a }) + Text(a?.label ?: "Terminal (shell)") + } + } + if (agent == Agent.CLAUDE && accounts.isNotEmpty()) { + item { Text("Claude account", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp)) } + items(listOf(null) + accounts.map { it.id }, key = { "acct-${it ?: "system"}" }) { id -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = accountId == id, onClick = { accountPick = id }) + Text(id?.let { AccountNames.managed(it, snapshot.status) } ?: AccountNames.SYSTEM) + } + } + } + } + } + }, + confirmButton = { + if (managedState is ManagedSessionCreation.State.Ready) { + TextButton(onClick = onOpenManaged) { Text("Open session") } + } else if (managedState is ManagedSessionCreation.State.Uncertain) { + TextButton(onClick = onCheckedManaged) { Text("I checked the computer") } + } else TextButton(enabled = selected != null && editable, onClick = start@{ + val p = selected ?: return@start + val a = agent + val acct = if (a == Agent.CLAUDE) accountId else null + if (onManagedCreate != null) { + onManagedCreate(ManagedSessionChoice(projectId = p.id, kind = if (a == null) "shell" else "agent", + agentId = a?.id, accountId = acct, title = a?.label ?: "Terminal")) + return@start + } + val cmd = if (a != null) Launch.launchCommand(a, settings, acct, p.cwd, p.defaultPermissionMode) + else p.cwd?.let { if (Regex("^/[^'\\u0000-\\u001f]*$").matches(it)) "cd '$it'" else null } + onCreate( + LaunchRequest( + nodeId = Launch.newNodeId(), + projectId = p.id, + title = a?.label ?: "Terminal", + agentId = a?.id, + accountId = acct, + command = cmd + ) + ) + }) { Text("Start") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt new file mode 100644 index 0000000000..86bdb11938 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt @@ -0,0 +1,321 @@ +package dev.nodeterm.android.ui + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.data.RoutePreference +import dev.nodeterm.android.data.DictationPreferences +import dev.nodeterm.protocol.model.DictationLanguage +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.ssh.SshProfilePath +import dev.nodeterm.android.notify.InboxNotifier +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.relay.ApiBaseSetting +import dev.nodeterm.protocol.relay.RelayApi +import java.security.MessageDigest + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(nav: Navigator) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val hosts by graph.hosts.hosts.collectAsState() + var name by remember { mutableStateOf(graph.hosts.deviceName) } + var apiBase by remember { mutableStateOf(graph.hosts.apiBase) } + var notify by remember { mutableStateOf(graph.hosts.notificationsEnabled) } + var notifyDetails by remember { mutableStateOf(graph.hosts.notificationDetails) } + val dictationPreferences = remember { DictationPreferences(context.applicationContext) } + var dictationLanguage by remember { mutableStateOf(dictationPreferences.languageTag) } + var chooseDictationLanguage by remember { mutableStateOf(false) } + // Whether the phone will actually SHOW them, re-read whenever the screen starts (the user may + // have come back from the system settings). The switch used to read On while nothing could + // arrive (audit A21). + var canPost by remember { mutableStateOf(InboxNotifier.canPost(context)) } + LifecycleStartEffect(Unit) { + canPost = InboxNotifier.canPost(context) + onStopOrDispose { } + } + val askPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + canPost = InboxNotifier.canPost(context) + } + fun openNotificationSettings() { + runCatching { + context.startActivity( + Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName) + ) + } + } + var routes by remember { mutableStateOf(hosts.associate { it.id to graph.hosts.route(it.id) }) } + + // The name and the relay address are stored when the screen is left, by the top-bar arrow AND + // by the system back (gesture or button), which used to pop without saving (audit A44). Not per + // keystroke: a half-typed address would be the one the relay calls used. An address that is not + // https is not stored, and the user is told so rather than finding the old one still in place. + // Leaving without an edit stores nothing. While nothing is stored, the name follows the phone's + // model and the address the build's default relay; storing either would pin it. + fun save(onRejected: () -> Unit) { + if (name.trim() != graph.hosts.deviceName) graph.hosts.deviceName = name + when (val edit = ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase)) { + is ApiBaseSetting.OnLeave.Save -> graph.hosts.apiBase = edit.value + ApiBaseSetting.OnLeave.UseDefault -> graph.hosts.useDefaultApiBase() + ApiBaseSetting.OnLeave.Keep -> {} + ApiBaseSetting.OnLeave.Rejected -> onRejected() + } + } + fun leave() { + save(onRejected = { + Toast.makeText( + context, + "Relay API not saved: it must be a full https:// address.", + Toast.LENGTH_LONG + ).show() + }) + nav.pop() + } + // Registered after AppContent's, so it runs instead of that plain pop while Settings shows. + BackHandler(enabled = nav.size > 1) { leave() } + // Settings also leaves the screen without a back (the review of A44): a notification tap replaces + // the whole stack, and a pairing link pushes its screen on top. The edits are stored then too, but + // silently: a refused address is not stored, and the user is not on this screen to be told. After + // leave() this finds nothing left to change, and no second message. A recreation of the + // activity (a rotation) stores what is typed by then as well; the new screen reads it back. + DisposableEffect(Unit) { + onDispose { save(onRejected = {}) } + } + + if (chooseDictationLanguage) { + DictationLanguageDialog( + selected = dictationLanguage, + onChoose = { tag -> + dictationPreferences.languageTag = tag + dictationLanguage = dictationPreferences.languageTag + chooseDictationLanguage = false + }, + onDismiss = { chooseDictationLanguage = false } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = { leave() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } + } + ) + } + ) { padding -> + Column( + Modifier.fillMaxSize().aboveKeyboard(padding).padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("This phone", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = name, + onValueChange = { name = it.take(60) }, + label = { Text("Name shown on your computer") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Notifications") + Text( + "Checked about every 15 minutes in the background, and live while this computer or All computers is on screen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = notify && canPost, onCheckedChange = { + notify = it + graph.hosts.notificationsEnabled = it + InboxNotifier.schedule(context, it) + if (it && !canPost) { + // Ask once through the system dialog; once Android stops showing it (after + // two denials) only the system settings can turn them back on. + if (Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + askPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + openNotificationSettings() + } + } + }) + } + if (notify && !canPost) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Notifications are turned off for nodeterm on this phone.", + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton(onClick = { openNotificationSettings() }) { Text("Open settings") } + } + } + // Off by default: the event's own text reaches the lock screen under Android's default + // setting, and a public version does not change that (audit A52). + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Show details in notifications") + Text( + "Adds the command, file or question and the agent's last message. Android then shows them " + + "on the lock screen too, unless you hide sensitive notification content there.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + // Enabled like the Notifications switch reads: on only while notifications can show. + Switch(checked = notifyDetails, enabled = notify && canPost, onCheckedChange = { + notifyDetails = it + graph.hosts.notificationDetails = it + }) + } + + HorizontalDivider() + Text("Dictation", style = MaterialTheme.typography.titleMedium) + Text("Language: " + DictationLanguage.label(dictationLanguage)) + TextButton(onClick = { chooseDictationLanguage = true }) { Text("Choose language") } + Text("Dictation fills the terminal draft. Tap Send when it is ready.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + + if (hosts.isNotEmpty()) { + HorizontalDivider() + Text("How to reach each computer", style = MaterialTheme.typography.titleMedium) + hosts.forEach { host -> + Text(host.name, style = MaterialTheme.typography.labelLarge) + SshProfileField(host) { path -> graph.connections.session(host.id).changeSshProfile(path) } + // Added by its SSH address (audit A27): SSH is its only route, so there is no choice. + if (host.manual) { + Text( + "Over SSH only: this computer was added by its SSH address, so it has no relay.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + return@forEach + } + listOf( + RoutePreference.AUTO to "Automatic (network first, then relay)", + RoutePreference.SSH_ONLY to "Only on my network (SSH)", + RoutePreference.RELAY_ONLY to "Only through the relay" + ).forEach { (route, label) -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = routes[host.id] == route, onClick = { + graph.hosts.setRoute(host.id, route) + graph.connections.session(host.id).disconnect() + routes = routes + (host.id to route) + }) + Text(label) + } + } + } + } + + HorizontalDivider() + Text("Identity", style = MaterialTheme.typography.titleMedium) + Text( + "SSH key: SHA256:" + B64.encode(MessageDigest.getInstance("SHA-256").digest(graph.sshIdentity.publicKeyBlob)).trimEnd('='), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall + ) + Text( + "Relay key: " + graph.boxKeys.publicKeyB64.take(16) + "…", + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall + ) + Text( + "Both keys were made on this phone and never leave it. Your computer keeps only their public halves.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider() + Text("Advanced", style = MaterialTheme.typography.titleMedium) + // By what the field holds now, so an unusable address an older build stored is flagged too. + val apiBaseError = ApiBaseSetting.fieldError(apiBase, graph.hosts.apiBase) + OutlinedTextField( + value = apiBase, + onValueChange = { apiBase = it.trim() }, + label = { Text("Relay API (https)") }, + isError = apiBaseError != null, + supportingText = apiBaseError?.let { error -> { Text(error) } }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + TextButton(onClick = { apiBase = RelayApi.DEFAULT_API_BASE }) { Text("Reset to default") } + + HorizontalDivider() + Text("About", style = MaterialTheme.typography.titleMedium) + Text( + "nodeterm for Android · open source components: xterm.js (MIT), ZXing (Apache-2.0), sshj (Apache-2.0), " + + "BouncyCastle (MIT), EdDSA-Java (CC0), OkHttp (Apache-2.0), kotlinx (Apache-2.0), AndroidX (Apache-2.0).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun SshProfileField(host: PairedHost, save: (String?) -> Unit) { + var input by remember(host.id, host.sshProfilePath) { mutableStateOf(host.sshProfilePath ?: "") } + var error by remember(host.id, host.sshProfilePath) { mutableStateOf(null) } + OutlinedTextField( + value = input, + onValueChange = { input = it; error = null }, + label = { Text("SSH profile folder") }, + supportingText = { Text(error ?: "Full path on this computer. Leave blank for automatic discovery. Saving disconnects its current session.") }, + isError = error != null, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + TextButton(onClick = { + error = input.takeIf { it.isNotBlank() }?.let(SshProfilePath::error) + if (error == null) save(SshProfilePath.fromInput(input)) + }, enabled = input != (host.sshProfilePath ?: "")) { Text("Save profile folder") } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SourceControlScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SourceControlScreen.kt new file mode 100644 index 0000000000..8ce18fc411 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SourceControlScreen.kt @@ -0,0 +1,540 @@ +package dev.nodeterm.android.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.LifecycleStartEffect +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.android.conn.ConnState +import dev.nodeterm.protocol.git.GitConflict +import dev.nodeterm.protocol.git.GitDiff +import dev.nodeterm.protocol.git.GitFileChange +import dev.nodeterm.protocol.git.GitHistory +import dev.nodeterm.protocol.git.GitResult +import dev.nodeterm.protocol.git.GitStatus +import dev.nodeterm.protocol.git.SourceControl +import dev.nodeterm.protocol.git.SourceControlGate +import dev.nodeterm.protocol.host.Capability +import kotlinx.coroutines.launch + +/** Whose diff is open. */ +private sealed interface DiffTarget { + val path: String + + /** A changed file, on one side ([staged]) of it. */ + data class File(val file: GitFileChange, val staged: Boolean) : DiffTarget { + override val path get() = file.path + } + + /** An unmerged path: its working tree with the conflict markers, never the untracked form. */ + data class Conflict(val conflict: GitConflict) : DiffTarget { + override val path get() = conflict.path + } +} + +/** + * A project's source control (audit A29), over the desktop's typed, jailed git bridge (`git.*`): the + * status split into conflicts, staged, changed and untracked files, a file's diff, stage and unstage, + * a commit of what is staged, push and pull, and the recent commits. The folder is the project's own, as + * `projects.list` names it; there is no free-form git. + * + * The shared capability routing uses the open connection when it serves typed Git, including + * direct SSH in listed local folders, else the relay leg opened on the tap that opens this screen. + * When neither can, or the project has no folder on the computer + * ([SourceControlGate]), the screen says why and sends nothing. What the computer answers is shown + * as it is: git's own message for a command that failed there, and the bridge's own sentence for a + * request it refused. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SourceControlScreen(nav: Navigator, hostId: String, projectId: String) { + val graph = NodetermApp.graph(LocalContext.current) + val host = graph.hosts.get(hostId) + if (host == null) { + LaunchedEffect(hostId) { nav.pop() } + return + } + val session = remember(hostId) { graph.connections.session(hostId) } + val state by session.state.collectAsState() + val snapshot by session.snapshot.collectAsState() + val relayApproval by session.relayApproval.collectAsState() + val scope = rememberCoroutineScope() + + // The project and its folder come from the listing, which this keeps current while showing, + // like every screen of a computer (audit A18). + LifecycleStartEffect(hostId) { + session.startWatching() + onStopOrDispose { session.stopWatching() } + } + + val listed = snapshot.fetchedAt != 0L + val project = snapshot.projects.firstOrNull { it.id == projectId } + val gate = SourceControlGate.of(project, session.route(Capability.GIT)) + val cwd = (gate as? SourceControlGate.Availability.Available)?.cwd + + var status by remember { mutableStateOf(null) } + var history by remember { mutableStateOf(null) } + var historyError by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(null) } + var notice by remember { mutableStateOf(null) } + var message by rememberSaveable { mutableStateOf("") } + var diffOf by remember { mutableStateOf(null) } + var diff by remember { mutableStateOf(null) } + var diffError by remember { mutableStateOf(null) } + var reload by remember { mutableIntStateOf(0) } + + /** The leg that serves `git.*` right now (a tap may open the relay leg next to SSH). */ + suspend fun git(dir: String) = SourceControl(session.connectionFor(Capability.GIT), dir) + + suspend fun load(dir: String, withHistory: Boolean) { + val g = git(dir) + val st = g.status() + status = st + if (withHistory) { + history = null + historyError = null + if (st.hasRepo) { + try { + history = g.history() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + historyError = e.message ?: "The history could not be read." + } + } + } + } + + /** + * One operation at a time. [block] answers what the computer said, and the status is read again + * afterwards WHATEVER happened ([SourceControl.writeThenReload]): a failed push still tells the + * truth about ahead/behind, and a commit that got no answer may have landed all the same. + */ + fun run(label: String, withHistory: Boolean, block: suspend (SourceControl) -> GitResult?) { + val dir = cwd ?: return + if (busy != null) return + busy = label + notice = null + scope.launch { + try { + notice = SourceControl.writeThenReload(label, { block(git(dir)) }, { load(dir, withHistory) }) + } finally { + busy = null + } + } + } + + LaunchedEffect(cwd, reload) { + val dir = cwd ?: return@LaunchedEffect + busy = "Loading" + notice = null + try { + load(dir, withHistory = true) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + notice = SourceControl.Outcome(error = true, text = e.message ?: "Source control could not be read.") + } finally { + busy = null + } + } + + LaunchedEffect(diffOf) { + val target = diffOf ?: return@LaunchedEffect + val dir = cwd ?: return@LaunchedEffect + diff = null + diffError = null + try { + diff = when (target) { + is DiffTarget.File -> git(dir).diff(target.file, target.staged) + is DiffTarget.Conflict -> git(dir).diff(target.conflict) + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + diffError = e.message ?: "The diff could not be read." + } + } + + BackHandler(enabled = diffOf != null) { diffOf = null } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(diffOf?.path ?: "Source control", maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + diffOf?.let { diffSubtitle(it) } + ?: listOfNotNull(project?.name, status?.takeIf { it.hasRepo }?.branch).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + IconButton(onClick = { if (diffOf != null) diffOf = null else nav.pop() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + }, + actions = { + if (diffOf == null) { + IconButton(onClick = { reload++ }, enabled = cwd != null && busy == null) { Icon(Icons.Filled.Refresh, "Refresh") } + } + } + ) + } + ) { padding -> + Column(Modifier.fillMaxSize().aboveKeyboard(padding)) { + if (state is ConnState.AwaitingApproval) ApprovalCode((state as ConnState.AwaitingApproval).sas) + // The relay leg opened next to SSH for this screen waits for the computer's approval + // dialog (its first dial on a desktop that has not pinned this phone). + relayApproval?.let { if (state !is ConnState.AwaitingApproval) ApprovalCode(it) } + (state as? ConnState.Failed)?.let { Banner(it.message, error = true) } + if (busy != null) LinearProgressIndicator(Modifier.fillMaxWidth()) + notice?.let { n -> Banner(n.text, error = n.error, onDismiss = { notice = null }) } + + when { + !listed -> Centered("Loading the project…") + gate is SourceControlGate.Availability.Unavailable -> Centered(gate.reason) + diffOf != null -> DiffView(diff, diffError) + status == null -> Centered(if (busy != null) "Reading the repository…" else "The repository could not be read.") + status?.hasRepo == false -> Centered( + "The project's folder is not a git repository. Create one in nodeterm on the computer (Source Control → Initialize repository)." + ) + else -> StatusList( + status = status!!, + history = history, + historyError = historyError, + message = message, + onMessage = { message = it }, + busy = busy != null, + onOpen = { file, staged -> diffOf = DiffTarget.File(file, staged) }, + onOpenConflict = { diffOf = DiffTarget.Conflict(it) }, + onStage = { paths -> run("Stage", withHistory = false) { it.stage(paths) } }, + onUnstage = { paths -> run("Unstage", withHistory = false) { it.unstage(paths) } }, + onCommit = { + val text = message + run("Commit", withHistory = true) { g -> + g.commit(text).also { if (it.ok) message = "" } + } + }, + onPush = { run("Push", withHistory = true) { it.push() } }, + onPull = { run("Pull", withHistory = true) { it.pull() } } + ) + } + } + } +} + +private fun diffSubtitle(target: DiffTarget): String = when (target) { + is DiffTarget.Conflict -> "Conflict · ${target.conflict.description}" + is DiffTarget.File -> if (target.staged) "Staged changes" else if (target.file.untracked) "New file" else "Changes" +} + +@Composable +private fun Centered(text: String) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun Banner(text: String, error: Boolean, onDismiss: (() -> Unit)? = null) { + Text( + text, + Modifier + .fillMaxWidth() + .background(if (error) NtColors.attention.copy(alpha = 0.12f) else NtColors.panel2) + .let { if (onDismiss != null) it.clickable(onClick = onDismiss) else it } + .padding(12.dp), + style = MaterialTheme.typography.bodySmall + ) +} + +@Composable +private fun StatusList( + status: GitStatus, + history: GitHistory?, + historyError: String?, + message: String, + onMessage: (String) -> Unit, + busy: Boolean, + onOpen: (GitFileChange, Boolean) -> Unit, + onOpenConflict: (GitConflict) -> Unit, + onStage: (List) -> Unit, + onUnstage: (List) -> Unit, + onCommit: () -> Unit, + onPush: () -> Unit, + onPull: () -> Unit +) { + val commitBlocker = SourceControl.commitBlocker(status, message) + val syncBlocker = SourceControl.syncBlocker(status) + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 32.dp)) { + item(key = "branch") { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(status.branch, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.width(8.dp)) + val sync = buildList { + if (status.ahead > 0) add("↑${status.ahead}") + if (status.behind > 0) add("↓${status.behind}") + if (status.hasRemote && !status.hasUpstream) add("not pushed yet") + }.joinToString(" ") + if (sync.isNotEmpty()) Text(sync, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (status.repoName.isNotBlank()) { + Text(status.repoName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onPull, enabled = !busy && syncBlocker == null) { Text("Pull") } + OutlinedButton(onClick = onPush, enabled = !busy && syncBlocker == null) { Text("Push") } + } + syncBlocker?.let { Hint(it) } + } + } + item(key = "commit") { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = message, + onValueChange = onMessage, + label = { Text("Commit message") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 6 + ) + Button(onClick = onCommit, enabled = !busy && commitBlocker == null) { + Text(if (status.staged.isEmpty()) "Commit" else "Commit ${status.staged.size} staged") + } + // Says why only once there is something to say it about. + if (commitBlocker != null && (message.isNotBlank() || status.staged.isNotEmpty() || status.conflicts.isNotEmpty())) { + Hint(commitBlocker) + } + } + } + conflictSection(status.conflicts, onOpenConflict) + fileSection( + key = "staged", title = "Staged", files = status.staged, staged = true, + action = "Unstage", allAction = "Unstage all", busy = busy, + onOpen = onOpen, onAct = onUnstage + ) + fileSection( + key = "changes", title = "Changes", files = status.unstaged, staged = false, + action = "Stage", allAction = "Stage all", busy = busy, + onOpen = onOpen, onAct = onStage + ) + fileSection( + key = "untracked", title = "Untracked", files = status.untracked, staged = false, + action = "Stage", allAction = "Stage all", busy = busy, + onOpen = onOpen, onAct = onStage + ) + if (status.clean) { + item(key = "clean") { Hint("No changes.", Modifier.padding(16.dp)) } + } + item(key = "history-title") { SectionTitle("Recent commits", null) } + when { + historyError != null -> item(key = "history-error") { Hint(historyError, Modifier.padding(horizontal = 16.dp)) } + history == null -> item(key = "history-loading") { Hint("Loading…", Modifier.padding(horizontal = 16.dp)) } + history.commits.isEmpty() -> item(key = "history-empty") { Hint("No commits yet.", Modifier.padding(horizontal = 16.dp)) } + else -> { + items(history.commits, key = { "c-${it.id}" }) { c -> + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(c.shortId, fontFamily = FontFamily.Monospace, color = NtColors.accent, fontSize = 12.sp) + Spacer(Modifier.width(8.dp)) + Text(c.subject, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + val meta = listOfNotNull( + c.author, + c.timestampMs?.let { relativeAge(it) }, + c.refs.takeIf { it.isNotEmpty() }?.joinToString(", ") + ).joinToString(" · ") + if (meta.isNotEmpty()) Hint(meta) + } + } + if (history.hasMore) item(key = "history-more") { Hint("Older commits are in nodeterm on the computer.", Modifier.padding(16.dp)) } + } + } + } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.fileSection( + key: String, + title: String, + files: List, + staged: Boolean, + action: String, + allAction: String, + busy: Boolean, + onOpen: (GitFileChange, Boolean) -> Unit, + onAct: (List) -> Unit +) { + if (files.isEmpty()) return + // A row's key is its PATH, which can be any name ("header" included), so rows live under + // "
-file:" and the heading under "
-header": a repository-root file named like + // the heading must not share its key, or the list throws on a duplicate key. + item(key = "$key-header") { + SectionTitle("$title (${files.size})") { + TextButton(onClick = { onAct(files.map { it.path }) }, enabled = !busy) { Text(allAction) } + } + } + items(files, key = { "$key-file:${it.path}" }) { file -> + Row( + Modifier.fillMaxWidth().clickable { onOpen(file, staged) }.padding(start = 16.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + file.status, + Modifier.widthIn(min = 18.dp), + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = statusColor(file.status) + ) + Spacer(Modifier.width(8.dp)) + Text(file.path, Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + if (file.added > 0 || file.deleted > 0) { + Text("+${file.added} −${file.deleted}", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + TextButton(onClick = { onAct(listOf(file.path)) }, enabled = !busy) { Text(action) } + } + } +} + +/** + * Unmerged paths: opened as their combined diff, and offered no Stage. `git add` on one marks the + * conflict resolved with whatever the file holds, markers included, and the phone cannot edit it. + */ +private fun androidx.compose.foundation.lazy.LazyListScope.conflictSection( + conflicts: List, + onOpen: (GitConflict) -> Unit +) { + if (conflicts.isEmpty()) return + item(key = "conflicts-header") { SectionTitle("Conflicts (${conflicts.size})", null) } + item(key = "conflicts-hint") { + Hint( + "Resolve these on the computer or in a terminal session, then stage them. The phone does not mark a conflict resolved.", + Modifier.padding(horizontal = 16.dp) + ) + } + items(conflicts, key = { "conflicts-file:${it.path}" }) { c -> + Row( + Modifier.fillMaxWidth().clickable { onOpen(c) }.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(c.code, Modifier.widthIn(min = 26.dp), fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, color = NtColors.attention) + Spacer(Modifier.width(8.dp)) + Text(c.path, Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(c.description, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +private fun SectionTitle(text: String, trailing: (@Composable () -> Unit)?) { + Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 14.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text.uppercase(), + Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + trailing?.invoke() + } +} + +@Composable +private fun Hint(text: String, modifier: Modifier = Modifier) { + Text(text, modifier, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +/** + * The letters the desktop's Source Control colours (`gitStatusColors`): added/new, deleted, the rest. + * A `U` here is an untracked file: unmerged paths are in their own section ([conflictSection]). + */ +private fun statusColor(status: String): Color = when (status) { + "A", "U" -> NtColors.success + "D" -> NtColors.attention + "R", "C" -> NtColors.accent + else -> NtColors.warning +} + +@Composable +private fun DiffView(diff: GitDiff?, error: String?) { + when { + error != null -> Centered(error) + diff == null -> Centered("Reading the diff…") + diff.isEmpty -> Centered("No differences to show.") + else -> LazyColumn(Modifier.fillMaxSize().padding(horizontal = 4.dp), contentPadding = PaddingValues(bottom = 32.dp)) { + items(diff.lines.size) { i -> + val line = diff.lines[i] + val (bg, fg) = when (line.kind) { + GitDiff.Kind.ADD -> NtColors.success.copy(alpha = 0.14f) to MaterialTheme.colorScheme.onSurface + GitDiff.Kind.DEL -> NtColors.attention.copy(alpha = 0.14f) to MaterialTheme.colorScheme.onSurface + GitDiff.Kind.HUNK -> NtColors.accent.copy(alpha = 0.10f) to NtColors.accent + GitDiff.Kind.META, GitDiff.Kind.NOTE -> Color.Transparent to MaterialTheme.colorScheme.onSurfaceVariant + GitDiff.Kind.CONTEXT -> Color.Transparent to MaterialTheme.colorScheme.onSurface + } + Text( + line.text.ifEmpty { " " }, + Modifier.fillMaxWidth().background(bg, RoundedCornerShape(2.dp)).padding(horizontal = 6.dp, vertical = 1.dp), + color = fg, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 16.sp + ) + } + if (diff.omitted > 0) { + item { Hint("${diff.omitted} more lines are not shown. The whole diff is in nodeterm on the computer.", Modifier.padding(12.dp)) } + } + } + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/SshTerminalDialog.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SshTerminalDialog.kt new file mode 100644 index 0000000000..3a5631cec3 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/SshTerminalDialog.kt @@ -0,0 +1,92 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.SshTerminalCreation +import dev.nodeterm.protocol.model.SshTerminalFolders +import dev.nodeterm.protocol.ssh.PhoneTerminals + +@Composable +internal fun SshTerminalDialog( + snapshot: ProjectsSnapshot, + state: SshTerminalCreation.State, + onDismiss: () -> Unit, + onCreate: (String?) -> Unit, + onOpen: () -> Unit, +) { + val request = when (state) { + is SshTerminalCreation.State.Creating -> state.request + is SshTerminalCreation.State.Failed -> state.request + is SshTerminalCreation.State.Ready -> state.request + SshTerminalCreation.State.Idle -> null + } + var folder by remember { mutableStateOf(request?.cwd) } + var custom by remember { mutableStateOf(request?.cwd ?: "") } + var editingCustom by remember { mutableStateOf(false) } + val busy = state is SshTerminalCreation.State.Creating + val ready = state is SshTerminalCreation.State.Ready + val editable = state == SshTerminalCreation.State.Idle || (state is SshTerminalCreation.State.Failed && state.canChangeFolder) + val selected = if (editingCustom) custom else folder + val valid = selected == null || PhoneTerminals.validCwd(selected) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New terminal") }, + text = { + Column(Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("A plain shell on this computer. It remains available when you close the app.") + if (editable) { + SshTerminalFolders.choices(snapshot).forEach { choice -> + TextButton(onClick = { folder = choice.cwd; editingCustom = false }) { + Text((if (!editingCustom && folder == choice.cwd) "✓ " else "") + choice.label) + } + } + OutlinedTextField( + value = custom, + onValueChange = { custom = it; editingCustom = true }, + label = { Text("Absolute folder path") }, + placeholder = { Text("/path/to/folder") }, + singleLine = true, + isError = editingCustom && !valid, + modifier = Modifier.fillMaxWidth() + ) + if (editingCustom && !valid) Text("Choose an absolute folder without control characters.", color = MaterialTheme.colorScheme.error) + } else { + Text(request?.cwd ?: "Home folder") + } + when (state) { + is SshTerminalCreation.State.Creating -> Text("Creating… You can close this dialog while it finishes.") + is SshTerminalCreation.State.Failed -> { + Text(state.message, color = MaterialTheme.colorScheme.error) + if (!state.canChangeFolder) Text("Retry checks the same terminal and folder to avoid creating a duplicate.") + } + is SshTerminalCreation.State.Ready -> Text("Terminal created. Open it when you are ready.") + SshTerminalCreation.State.Idle -> Unit + } + } + }, + confirmButton = { + TextButton(onClick = { if (ready) onOpen() else onCreate(if (editable) selected else request?.cwd) }, enabled = !busy && valid) { + Text(if (ready) "Open terminal" else if (state is SshTerminalCreation.State.Failed) "Retry" else "Create") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(if (busy) "Close" else "Cancel") } } + ) +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt new file mode 100644 index 0000000000..f7cd085908 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt @@ -0,0 +1,1152 @@ +package dev.nodeterm.android.ui + +import dev.nodeterm.android.conn.ConnState +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.flow.first +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.os.TransactionTooLargeException +import android.util.Base64 +import android.view.Choreographer +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.webkit.JavascriptInterface +import android.webkit.RenderProcessGoneDetail +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import dev.nodeterm.android.AppGraph +import dev.nodeterm.android.conn.HostSession +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.ComposedInput +import dev.nodeterm.protocol.host.ComposedInputResult +import dev.nodeterm.protocol.host.ComposedPreparation +import dev.nodeterm.protocol.host.ComposedCompletion +import dev.nodeterm.protocol.host.NeedsRelayException +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.host.RelayConnectStatus +import dev.nodeterm.protocol.host.RendererRecovery +import dev.nodeterm.protocol.host.ResumeOffer +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.NewSessionHint +import dev.nodeterm.protocol.host.PhoneLaunch +import dev.nodeterm.protocol.host.StreamLease +import dev.nodeterm.protocol.host.TerminalPage +import dev.nodeterm.protocol.host.TerminalOutput +import dev.nodeterm.protocol.host.TerminalActions +import dev.nodeterm.protocol.host.TerminalExit +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.TerminalScrollView +import dev.nodeterm.protocol.host.ViewerSlot +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.CtrlModifier +import dev.nodeterm.protocol.model.ExternalLink +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.InputBar +import dev.nodeterm.protocol.model.Keys +import dev.nodeterm.protocol.model.OnScreen +import dev.nodeterm.protocol.model.Osc52 +import dev.nodeterm.protocol.model.TerminalCopy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +sealed interface TermState { + data object Connecting : TermState + data object Attached : TermState + data class Ended(val message: String) : TermState + /** Direct SSH refused this session (not running, or on another host — audit A08/A09); the + * relay can open it. Shown with an "Open through the relay" button. */ + data class RelayOffer(val message: String) : TermState + /** Opening through the relay for the first time: the computer shows this code to approve. */ + data class AwaitingApproval(val sas: String) : TermState + /** + * The terminal view's renderer crashed, or kept being killed (audit A45). "Reopen terminal" builds + * a new view and reattaches to it. Not automatic: see [RendererRecovery]. + */ + data class ViewLost(val message: String) : TermState +} + +/** + * One terminal screen's plumbing: the WebView running xterm.js on one side, a [TerminalStream] on + * the other. Output is batched onto the main thread every frame (one `evaluateJavascript` per + * frame, not per packet); input, resize and scroll go the other way from the JS bridge thread. + */ +class TerminalController( + private val graph: AppGraph, + private val session: HostSession, + private val nodeId: String, + initialCtrl: CtrlModifier = CtrlModifier(), + private val onCtrlChanged: (CtrlModifier) -> Unit = {} +) { + var state by mutableStateOf(TermState.Connecting) + private set + var managedReceiptBlocked by mutableStateOf(false) + private set + + /** Explicit user decision after inspecting an uncertain/stale creation. No attach or launch. */ + fun discardManagedReceipt(): Boolean { + if (!managedReceiptBlocked || attached) return false + session.managedSessionCreation.adopted(nodeId) + managedReceiptBlocked = false + session.refresh() + return true + } + + /** + * Input can reach the pane. Read [state], not [stream]: the two change together in one main-thread + * post, and every caller of this is on the main thread (A41). Anything sent while it is false + * reached a null stream and was dropped silently. + */ + val attached: Boolean get() = state == TermState.Attached + + /** + * Whether the session's pane is in front of the user, for the live notifications (audit A73 + * review): attached shows it; connecting is where every start begins, and ends in the pane or in + * an overlay; an overlay (ended, relay offer, approval code, lost view) covers it. Read by a + * refresh on a background thread: [state] is snapshot state, which any thread may read. + */ + val pane: OnScreen.Pane + get() = when (state) { + TermState.Attached -> OnScreen.Pane.SHOWN + TermState.Connecting -> OnScreen.Pane.OPENING + else -> OnScreen.Pane.HIDDEN + } + + /** + * Offered after an attach of an agent node: its resume line after a COLD attach (the desktop's + * cold restore), or its wake line when the session is Sleeping over direct SSH (audit A76). + * Typed only when the user taps it. See [ResumeOffer]. + * + * It stays on screen while the stream is down: a reattach of this screen is warm (the cold attach + * created the session), so an unanswered resume is handed back to [ResumeOffer.afterAttach] as + * `carried` and kept while the computer still describes that conversation (the A41 review). + */ + var resumeOffer by mutableStateOf(null) + private set + + /** The connection of the attach that settled [resumeOffer]: a tapped wake re-reads its pane. */ + private var resumeConn: HostConnection? = null + + /** + * [resumeOffer] was decided by the attach the screen shows now, so a tap may type it. False from + * the start of every attach until its offer is settled: a carried offer is not typed on the strength + * of the previous attach while the reattach is still checking it. + */ + private var resumeSettled by mutableStateOf(false) + + /** The offer's button may be tapped: attached, and the offer settled by this attach. */ + val canResume: Boolean get() = attached && resumeSettled + /** A desktop viewer sized the shared pty differently from this screen. */ + var sizedElsewhere by mutableStateOf?>(null) + private set + private var ctrlModifier by mutableStateOf(initialCtrl) + private fun updateCtrl(modifier: CtrlModifier) { + if (modifier == ctrlModifier) return + ctrlModifier = modifier + onCtrlChanged(modifier) + } + var ctrlArmed: Boolean + get() = ctrlModifier.armed + set(value) { updateCtrl(ctrlModifier.withArmed(value)) } + var submitting by mutableStateOf(false) + private set + + /** A one-line message over the terminal (dismissable), e.g. a session the desktop refused to add. */ + var notice by mutableStateOf(null) + + /** + * A link the user tapped in the terminal (or clicked with a mouse), offered as "Open ?" + * (audit A32). Nothing opens on the tap itself: a tap meant for scrolling or for the pane can land on + * a link, and an OSC 8 link shows a label, not where it goes. Set only from [ExternalLink.parse], so + * only an http(s) URL is ever offered. + */ + var linkOffer by mutableStateOf(null) + private set + + /** The Copy sheet's snapshot of the page's buffer (audit A32), or null while the sheet is closed. */ + var copySheet by mutableStateOf(null) + private set + var historyOpen by mutableStateOf(false) + private set + + /** + * Changes each time the WebView is lost with its renderer (audit A45). The screen keys its + * AndroidView on it, so a change builds a new WebView ([createWebView]) in place of the dead one. + */ + var webViewKey by mutableStateOf(0) + private set + + /** + * The screen shows a WebView at all. False after a renderer loss (audit A45) until the next attach + * is asked for ([attachWhenPageReady]: a button, a pending auto-reattach, or [onStart]). Only an + * automatic reattach of a visible screen, which [RendererRecovery] bounds, builds the replacement at + * once: built unasked, a page whose renderer dies as it loads was rebuilt and lost over and over, + * since a kept answer or an offer is not counted (the review of A45). + */ + var hasWebView by mutableStateOf(true) + private set + + private val main = Handler(Looper.getMainLooper()) + private var webView: WebView? = null + /** The page in [webView], by generation, and the JavaScript waiting for it (A45). */ + private val page = TerminalPage() + private val recovery = RendererRecovery() + /** + * The attach hand-off (audit A40): which attach may still install its stream, and the stream this + * screen shows. Thread-safe; [stream] is also read on the WebView's bridge thread. + */ + private val slot = ViewerSlot() + private val stream: TerminalStream? get() = slot.stream + /** One ordered input drain per installed stream; retired before its viewer ticket changes. */ + @Volatile private var actions: TerminalActions? = null + private var attachJob: Job? = null + private var cols = 0 + private var rows = 0 + private var disposed = false + + private val output = TerminalOutput( + isCurrent = slot::isCurrent, + post = { delay, callback -> main.postDelayed({ callback() }, delay) }, + onPaint = { ticket, text -> jsViewer(ticket, "nt.paint('${b64(text.toByteArray(Charsets.UTF_8))}')") }, + onOutput = { ticket, bytes -> jsViewer(ticket, "nt.write('${b64(bytes)}')") } + ) + + /** + * One sink per attach, tied to its [ViewerSlot] ticket: a stream this screen no longer shows (a + * superseded attach, or one a launch still holds after the screen left) must not paint into it, + * and its exit is not this screen's to report. + */ + private fun sinkFor(ticket: Long) = object : TerminalSink { + override fun onPaint(text: String) { + output.paint(ticket, text) + } + + override fun onOutput(bytes: ByteArray) { + output.append(ticket, bytes) + } + + override fun onResized(cols: Int, rows: Int) { + main.post { + if (!slot.isCurrent(ticket)) return@post + sizedElsewhere = if (cols != this@TerminalController.cols || rows != this@TerminalController.rows) cols to rows else null + } + } + + override fun onExit(code: Int?) { + main.post { + // Not the stream this screen shows: our own detach on ON_STOP or on leaving (it ends the + // stream too), or a stream a superseded attach or a launch held. Not a drop to recover from. + if (!slot.isCurrent(ticket)) return@post + output.finish(ticket) + jsViewer(ticket, "nt.endViewer('$ticket')") + if (!slot.ended(ticket)) return@post + retireOutput() + retireActions() + // exit 0 with the session still running = another client attached with -D and + // detached us — audit A13. A current desktop no longer does this to a relay-attached + // phone, but an older desktop does, and any desktop still does it to a phone attached + // over direct SSH (its client is not one the desktop spawned, so it cannot see it). + // It is checked, not assumed: a killed session also exits 0. + if ((code == null || code == 0) && !disposed && autoReattach(requireLive = code == 0)) { + state = TermState.Ended(if (code == null) "Disconnected. Reconnecting…" else "Another screen took over this session. Reattaching…") + } else { + state = TermState.Ended(TerminalExit.closedMessage(code)) + } + } + } + } + + /** + * The page's side of the bridge, one per page ([gen], from [TerminalPage.build]). A callback of a + * page whose renderer is gone does nothing (A45): not even one posted just before the loss can + * mark the replacement page ready, resize the pty from the dead page's size, or type into it. + */ + inner class Bridge(private val gen: Int) { + @JavascriptInterface + fun onReady() { + main.post { + val queued = page.ready(gen) ?: return@post + val wv = webView + queued.forEach { wv?.evaluateJavascript(it, null) } + // A replacement page after the renderer was lost (A45): reattach now that it is loaded, + // unless its first resize below already did. Nothing to do on the first page, whose + // attach started with the screen. + attachIfWaiting() + } + } + + @JavascriptInterface + fun onResize(c: Int, r: Int) { + main.post { + if (c <= 0 || r <= 0 || !page.isCurrent(gen)) return@post + actions?.closeScrollView() + jsPage("nt.closeScrollView()") + cols = c + rows = r + sizedElsewhere = null + val s = stream + if (s != null) s.resize(c, r) else attachIfWaiting() + } + } + + @JavascriptInterface + fun onInput(data: String) { + val s = stream ?: return + if (!page.isCurrent(gen)) return + var out = data + val modifier = ctrlModifier + if (modifier.armed && data.length == 1) { + Keys.ctrl(data)?.let { out = it } + main.post { + val current = page.isCurrent(gen) && stream === s + updateCtrl(ctrlModifier.consume(modifier, current)) + } + } + writeInput(out, s) + } + + @JavascriptInterface + fun onScroll(up: Boolean, notches: Int) { + onScrollView(up, notches, 0) + } + + @JavascriptInterface + fun onScrollView(up: Boolean, notches: Int, displayEpoch: Int) { + val input = actions ?: return + if (!page.isCurrent(gen)) return + if (!input.scroll(up, notches, displayEpoch) && !input.scrollPaused) inputBusy() + } + + /** A new touch or page/input barrier stops unsent momentum without typing into the pane. */ + @JavascriptInterface + fun onScrollStop() { + val input = actions ?: return + if (!page.isCurrent(gen)) return + input.cancelScroll() + } + + @JavascriptInterface + fun onHistoryClose() { + if (page.isCurrent(gen)) actions?.closeScrollView() + } + + /** xterm mouse/focus/protocol reports preserve a pending gesture and an armed Ctrl. */ + @JavascriptInterface + fun onReport(data: String) { + val s = stream ?: return + if (!page.isCurrent(gen)) return + writeReport(data, s) + } + + /** The OSC 52 base64 cap terminal.js applies before a copy crosses this bridge (A53). */ + @JavascriptInterface + fun copyLimit(): Int = Osc52.MAX_BASE64 + + /** An OSC 52 terminal.js refused as over [copyLimit]; the payload itself never crossed. */ + @JavascriptInterface + fun onCopyTooLarge() { + if (!page.isCurrent(gen)) return + main.post { toast(COPY_TOO_LARGE, Toast.LENGTH_LONG) } + } + + /** + * A link the page found under a tap or a click (audit A32). The page sends only http(s) URLs; + * [ExternalLink.parse] checks that again, and anything else is dropped without a word. + */ + @JavascriptInterface + fun openUrl(url: String) { + if (!page.isCurrent(gen)) return + val link = ExternalLink.parse(url) ?: return + val expected = stream + main.post { if (!disposed && page.isCurrent(gen) && stream === expected) linkOffer = link } + } + + /** The buffer's lines and links for the Copy sheet, as `nt.copySheet` built them (audit A32). */ + @JavascriptInterface + fun onCopySheet(json: String) { + if (!page.isCurrent(gen)) return + val snapshot = TerminalCopy.parse(json) + val expected = stream + main.post { + if (disposed || !page.isCurrent(gen) || stream !== expected) return@post + if (snapshot == null) toast(COPY_SHEET_FAILED, Toast.LENGTH_SHORT) else copySheet = snapshot + } + } + + /** A whole OSC 52 sequence (`;`), parsed here on the bridge thread. */ + @JavascriptInterface + fun onCopy(data: String) { + if (!page.isCurrent(gen)) return + when (val r = Osc52.parse(data)) { + is Osc52.Result.Copy -> main.post { writeClipboard(r.text) } + Osc52.Result.TooLarge -> main.post { toast(COPY_TOO_LARGE, Toast.LENGTH_LONG) } + // A read query, an empty write or a malformed one: the desktop ignores these too. + Osc52.Result.Ignored, Osc52.Result.Invalid -> Unit + } + } + } + + /** + * The clipboard write is a binder call into the system server, and a failed one (too large for + * the shared transaction buffer, or refused) is rethrown here as a RuntimeException. Uncaught, + * pane output could crash the app (A53); caught, the user is told the copy did not happen. + */ + private fun writeClipboard(text: String, ctx: Context? = webView?.context, copied: String = linesCopied(text)) { + ctx ?: return + val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + if (cm == null) { + toast(COPY_FAILED, Toast.LENGTH_LONG, ctx) + return + } + try { + cm.setPrimaryClip(ClipData.newPlainText("nodeterm", text)) + } catch (e: Exception) { + toast(if (tooLarge(e)) COPY_REJECTED_SIZE else COPY_FAILED, Toast.LENGTH_LONG, ctx) + return + } + toast(copied, Toast.LENGTH_SHORT, ctx) + } + + private fun toast(message: String, length: Int, ctx: Context? = webView?.context) { + ctx ?: return + Toast.makeText(ctx, message, length).show() + } + + /** + * Open the Copy sheet (audit A32) with what the page's buffer holds now. Only a page that is loaded + * is asked: queued for one that is not, the sheet would pop up whenever that page came, unasked. + */ + fun openCopySheet() { + val wv = webView + if (wv == null || !page.isReady) { + toast(COPY_SHEET_NOT_READY, Toast.LENGTH_SHORT) + return + } + wv.evaluateJavascript("nt.copySheet()", null) + } + + fun closeCopySheet() { + copySheet = null + } + + fun openHistory() { historyOpen = true } + fun closeHistory() { historyOpen = false } + + suspend fun searchHistory(query: String): dev.nodeterm.protocol.model.TerminalHistory.Result { + val expected = stream ?: throw HostException("Wait for the terminal to connect, then try again.") + val result = expected.searchHistory(query) + if (stream !== expected || disposed || stopped) throw HostException("The terminal changed while its history was searched. Try again.") + return result + } + + fun copyHistory(ctx: Context, lines: List, selection: TerminalCopy.Selection) { + when (val t = TerminalCopy.text(lines, selection)) { + is TerminalCopy.Text.Copy -> writeClipboard(t.text, ctx) + TerminalCopy.Text.TooLarge -> toast(COPY_TOO_LARGE, Toast.LENGTH_LONG, ctx) + TerminalCopy.Text.Empty -> toast(NOTHING_SELECTED, Toast.LENGTH_SHORT, ctx) + } + } + + /** Copy the selected lines of the open sheet, within the OSC 52 copy's cap (audit A53). */ + fun copyLines(ctx: Context, selection: TerminalCopy.Selection) { + val lines = copySheet?.lines ?: return + when (val t = TerminalCopy.text(lines, selection)) { + is TerminalCopy.Text.Copy -> writeClipboard(t.text, ctx) + TerminalCopy.Text.TooLarge -> toast(COPY_TOO_LARGE, Toast.LENGTH_LONG, ctx) + TerminalCopy.Text.Empty -> toast(NOTHING_SELECTED, Toast.LENGTH_SHORT, ctx) + } + } + + /** + * Share the selected lines through the system's share sheet. The text rides the Intent, a binder + * transaction like the clipboard's, so it keeps to the same cap, and a refused one is caught. + */ + fun shareLines(ctx: Context, selection: TerminalCopy.Selection) { + val lines = copySheet?.lines ?: return + when (val t = TerminalCopy.text(lines, selection)) { + is TerminalCopy.Text.Copy -> { + val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, t.text) + start(ctx, Intent.createChooser(send, null), SHARE_FAILED) + } + TerminalCopy.Text.TooLarge -> toast(SHARE_TOO_LARGE, Toast.LENGTH_LONG, ctx) + TerminalCopy.Text.Empty -> toast(NOTHING_SELECTED, Toast.LENGTH_SHORT, ctx) + } + } + + /** + * Open [link] in the browser: the user tapped Open on the offer or in the Copy sheet, where its host + * and URL are shown. ACTION_VIEW, browsable apps only, and only ever an http(s) URL ([ExternalLink]). + */ + fun openLink(ctx: Context, link: ExternalLink) { + if (linkOffer == link) linkOffer = null + val view = Intent(Intent.ACTION_VIEW, Uri.parse(link.url)).addCategory(Intent.CATEGORY_BROWSABLE) + start(ctx, view, OPEN_FAILED) + } + + fun copyLink(ctx: Context, link: ExternalLink) { + if (linkOffer == link) linkOffer = null + writeClipboard(link.url, ctx, copied = "Copied the link") + } + + fun dismissLink() { + linkOffer = null + } + + /** startActivity that cannot crash the app: no app to take it, or a refused (too large) Intent. */ + private fun start(ctx: Context, intent: Intent, failed: String) { + if (ctx !is Activity) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + ctx.startActivity(intent) + } catch (e: ActivityNotFoundException) { + toast(NO_APP, Toast.LENGTH_LONG, ctx) + } catch (e: Exception) { + toast(if (tooLarge(e)) INTENT_TOO_LARGE else failed, Toast.LENGTH_LONG, ctx) + } + } + + @SuppressLint("SetJavaScriptEnabled") + fun createWebView(context: Context): WebView = WebView(context).apply { + val gen = page.build() + // AndroidView otherwise supplies WRAP_CONTENT. WebView uses that height policy to force + // Chromium's HTML layout height to zero, even when Compose measures a large native view; + // the page's full-height terminal then fits to one row (A85). + layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + setBackgroundColor(Color.BLACK) + settings.javaScriptEnabled = true + settings.allowFileAccess = false // assets stay readable; nothing else on disk is + settings.allowContentAccess = false + settings.setSupportZoom(false) + settings.builtInZoomControls = false + settings.displayZoomControls = false + // While the screen is not visible (its stream is detached then anyway), the renderer's priority + // is waived, so Android may reclaim it before the app; onRenderProcessGone below brings the + // terminal back (A45). While visible it keeps RENDERER_PRIORITY_IMPORTANT, WebView's default. + // RENDERER_PRIORITY_BOUND (the first A45 fix) waived it the same way, but also bound the + // VISIBLE renderer below that default, so memory pressure could take the terminal the user + // was looking at before it took the app (the review of A45). + setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_IMPORTANT, true) + addJavascriptInterface(Bridge(gen), "NodetermBridge") + webViewClient = object : WebViewClient() { + // The page never navigates, and nothing it does is opened from here: a link opens only + // through the bridge's openUrl, which asks first (audit A32). A navigation is refused. + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = true + + // The renderer was killed or crashed. The default (false) takes the whole app down with + // it; true keeps the app, which must then stop using this WebView (A45). + override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { + rendererGone(view, detail.didCrash()) + return true + } + } + loadUrl("file:///android_asset/terminal/index.html") + // A replacement built while the screen is in the background (A45) is paused like the one it + // replaces was on ON_STOP; onStart resumes it. + if (stopped) onPause() + webView = this + jsPage("nt.setFontSize(${graph.hosts.fontSize})") + } + + private fun js(code: String) { + val ticket = output.owner ?: return + jsViewer(ticket, code) + } + + private fun jsViewer(ticket: Long, code: String) { + if (disposed) return + if (page.offerViewer(ticket, code)) webView?.evaluateJavascript(code, null) + } + + private fun jsPage(code: String) { + if (disposed) return + // Queued until the page is ready, also while there is no page at all: after the renderer was + // lost, a reattach can paint before the replacement WebView is built (A45). + if (page.offer(code)) webView?.evaluateJavascript(code, null) + } + + private fun retireOutput() { + output.retire() + page.viewerChanged(null) + } + + private fun retireActions() { + actions?.close() + actions = null + jsPage("nt.closeScrollView()") + } + + private fun inputBusy() { + main.post { if (attached) notice = "Terminal input is busy. Wait a moment and try again." } + } + + private fun writeInput(data: String, expected: TerminalStream? = stream) { + val input = actions ?: return + if (expected == null || stream !== expected) return + if (!input.write(data)) inputBusy() + } + + private fun writeReport(data: String, expected: TerminalStream) { + val input = actions ?: return + if (stream !== expected) return + if (!input.report(data)) inputBusy() + } + + /** A native resume must wait for JS to discard its unsent swipe, then check the viewer again. */ + private fun writeAfterScrollCancel(data: String, expected: TerminalStream) { + val wv = webView ?: return + actions?.closeScrollView() + wv.evaluateJavascript("nt.cancelScroll();nt.closeScrollView()") { + if (webView === wv && stream === expected && attached) writeInput(data, expected) + } + } + + /** + * The renderer behind [view] is gone (audit A45): Android killed it to reclaim memory, or it + * crashed. That WebView can never be used again. The stream is detached (it would paint into + * nothing), the WebView leaves the view tree and is destroyed, and [webViewKey] changes so the + * screen builds a new one: at once for an automatic reattach, otherwise once the next attach is + * asked for ([hasWebView]). Then, per [RendererRecovery] and what the screen was showing: an answer + * with its own button (ended, disconnected, relay offer, view lost) stays, and nothing reattaches + * unasked; otherwise the screen reattaches by itself once the new page is ready (a kill), or offers + * "Reopen terminal" (a crash, or kills in a loop). + */ + private fun rendererGone(view: WebView, didCrash: Boolean) { + // A view this screen already let go of (a repeated callback, or one after dispose). + if (view !== webView) return + // Read before anything below changes it. + val showing = showing(state) + webView = null + retireOutput() + page.lost() + retireActions() + // Detached as on ON_STOP. A launch still holding the stream finishes first (A40), and the + // stream's exit is not reported: its ticket is retired. + attachJob?.cancel() + attachJob = null + slot.leave() + // The dead page's size. The new page reports its own before it reattaches. + cols = 0 + rows = 0 + sizedElsewhere = null + destroyWebView(view, rendererAlive = false) + webViewKey++ + // Monotonic: a wall clock changed meanwhile would stretch or clear the kill-loop window. A kill + // while stopped is expected (the priority is waived then) and not counted (the review of A45). + val outcome = recovery.onGone(didCrash, SystemClock.elapsedRealtime(), showing, visible = !stopped) + when (outcome) { + // The screen keeps its answer, and its button reattaches once the new page is ready + // (attachWhenPageReady). Moving it to Connecting would attach unasked: over the relay, to + // a pane that exited, that creates a new, empty session. + RendererRecovery.Outcome.Keep -> Unit + // Reattached by attachIfWaiting once the new page reports in (or by onStart, when the + // screen is in the background now). + RendererRecovery.Outcome.Reattach -> state = TermState.Connecting + is RendererRecovery.Outcome.Offer -> state = TermState.ViewLost(outcome.message) + } + // The replacement is built now only for an automatic reattach of a visible screen, which the + // bound above limits. Otherwise the next attach asked for builds it (attachWhenPageReady). + hasWebView = outcome == RendererRecovery.Outcome.Reattach && !stopped + } + + /** + * What [st] tells [RendererRecovery] (A45). Exhaustive on purpose: a new state has to say whether + * an attach is in flight behind it, and whether the screen knew the session was running. + */ + private fun showing(st: TermState): RendererRecovery.Showing = when (st) { + TermState.Attached -> RendererRecovery.Showing.ATTACHED + // An attach in flight (the loss retires it), or waiting for a page or for onStart. The code of + // a retired approval dial is not kept: nothing would be behind it, and it has no button. + TermState.Connecting, is TermState.AwaitingApproval -> RendererRecovery.Showing.OPENING + is TermState.Ended, is TermState.RelayOffer, is TermState.ViewLost -> RendererRecovery.Showing.SETTLED + } + + /** + * Take [view] out of the view tree, then destroy it: WebView.destroy() expects a view that is no + * longer attached. A view whose renderer is gone is not used for anything else. + */ + private fun destroyWebView(view: WebView, rendererAlive: Boolean) { + (view.parent as? ViewGroup)?.removeView(view) + if (rendererAlive) view.removeJavascriptInterface("NodetermBridge") + view.destroy() + } + + /** + * Attach if the screen is waiting to: started, nothing attached or attaching, and "Opening + * terminal…" showing. Run when the page reports its size or that it is ready, which is how a + * replacement page after a renderer loss gets its terminal back (A45). + */ + private fun attachIfWaiting() { + if (!disposed && !stopped && stream == null && attachJob == null && state == TermState.Connecting) attach() + } + + /** + * Attach now or, while a replacement page is still loading after a renderer loss (A45), once it + * has reported its size ([attachIfWaiting]): attaching before would claim the pty at 80×24. A + * replacement not built yet ([hasWebView]) is built now, this being the attach it waited for; + * not while stopped, where nothing attaches and [onStart] builds it. + */ + private fun attachWhenPageReady() { + if (page.isReplacing) { + if (!stopped) hasWebView = true + state = TermState.Connecting + } else { + attach() + } + } + + /** "Reopen terminal", after the view was lost with its renderer (A45). */ + fun reopenTerminal() { + if (state is TermState.ViewLost) attachWhenPageReady() + } + + /** + * "Reattach", from an ended or disconnected session. Through [attachWhenPageReady]: the screen + * keeps this answer over a page that is being replaced after a renderer loss (A45 review). + */ + fun reattach() { + if (state is TermState.Ended) attachWhenPageReady() + } + + private var attachedAt = 0L + private var autoReattaches = 0 + + /** + * A null exit is the CONNECTION going away, not the pane ending. The host connection reconnects + * on its own; this follows it back into the session instead of leaving "Disconnected" until the + * user taps Reattach (audit A36). Bounded: three tries per stretch of flapping (a stream that + * lived a minute resets the count), and only while this screen is showing. Returns false when + * out of tries. + */ + private fun autoReattach(requireLive: Boolean = false): Boolean { + if (System.currentTimeMillis() - attachedAt > 60_000) autoReattaches = 0 + if (autoReattaches >= 3) return false + autoReattaches++ + graph.scope.launch { + delay(1_500L * autoReattaches) + // A re-list notices a dead SSH transport (and drops it) before we ask for a connection, + // so the attach below does not get the stale one back. + if (!useRelay || requireLive) session.refreshNow() + if (requireLive && !session.snapshot.value.isLive(nodeId)) { + main.post { + if (!disposed && stream == null && state !is TermState.ViewLost) state = TermState.Ended("The session ended (exit 0).") + } + return@launch + } + val up = if (useRelay) true else withTimeoutOrNull(120_000) { session.state.first { it is ConnState.Connected } } != null + main.post { + // The view was lost meanwhile and is waiting for the user's "Reopen terminal" (A45). + if (disposed || stream != null || attachJob != null || state is TermState.ViewLost) return@post + if (up) attachWhenPageReady() else state = TermState.Ended("Disconnected.") + } + } + return true + } + + /** Set once the user chose "Open through the relay": later reattaches stay on the relay. */ + private var useRelay = false + + fun openThroughRelay() { + if (state !is TermState.RelayOffer) return + useRelay = true + // The offer can be on screen over a page being replaced (A45 review): wait for its size. + attachWhenPageReady() + } + + /** Attach now. The screen's buttons go through [attachWhenPageReady] ([reattach], [openThroughRelay]). */ + private fun attach() { + if (disposed || stopped) return + state = TermState.Connecting + // The offer stays on screen (A41 review): this attach settles it, keeping an unanswered + // resume or dropping it (afterAttach). Until then it cannot be typed. + resumeSettled = false + retireActions() + jsPage("nt.suspendScroll()") + val ticket = slot.begin() + page.viewerChanged(ticket) + output.begin(ticket) + val managed = session.managedSessionCreation.receiptFor(nodeId) + managedReceiptBlocked = false + attachJob = graph.scope.launch { + val job = coroutineContext[Job] + try { + val onStatus: (RelayConnectStatus) -> Unit = { st -> + // A superseded attach's dial must not put its code over the current screen. + if (st is RelayConnectStatus.AwaitingApproval) main.post { if (slot.isCurrent(ticket)) state = TermState.AwaitingApproval(st.sas) } + } + val conn = when { + managed != null -> session.ensureConnected().also { + if (it.kind != TransportKind.SSH) throw HostException("This host-created terminal must first be confirmed over SSH. It may already exist on the computer; check it before discarding the saved receipt.") + } + useRelay -> session.viaRelay(onStatus = onStatus) + // Legacy New goes through the relay. A managed receipt above is SSH attach-only: + // it already identifies a host-created pane and never enters PhoneLaunch. + PendingLaunches.peek(nodeId) != null -> session.connectionFor(Capability.REGISTER_NODE, onStatus = onStatus) + else -> session.ensureConnected() + } + withContext(Dispatchers.Main.immediate) { + if (slot.isCurrent(ticket)) jsViewer(ticket, "nt.beginViewer('$ticket',${conn.kind == TransportKind.SSH})") + } + val sink = sinkFor(ticket) + val c = if (cols > 0) cols else 80 + val r = if (rows > 0) rows else 24 + // A session this phone is starting: let the host create it in its project, under + // the chosen account (audit A33). + val hint = PendingLaunches.peek(nodeId)?.let { NewSessionHint(it.projectId, it.accountId, it.agentId) } + // ON_STOP cancels this job so nothing keeps connecting, or waiting for an approval, + // behind a screen nobody sees. That stops here: a screen that left before the attach + // went out sends nothing (a pending launch stays for its next attach). From the + // request on, nothing is cancelled: the host may already be creating the session and + // has reserved a viewer on it, and only a stream that arrives can be let go of (the + // hand-off below) and hand its launch on. A cancelled attach dropped both (audit A40). + ensureActive() + val (s, launch) = withContext(NonCancellable) { + val s = if (managed != null) conn.attachManagedSession(managed, c, r, sink) else conn.attach(nodeId, c, r, sink, hint) + val lease = StreamLease(s) { st -> graph.scope.launch { runCatching { st.detach() } } } + if (managed != null) runCatching { session.managedSessionCreation.adopted(nodeId) } + // The host created this session for the launch just now, and the request is + // consumed here: the launch holds the stream until it is done, whatever this + // screen does next (audit A40). Started BEFORE the hand-off, so a screen that + // already left cannot detach the stream under it. + val launch = if (managed == null && hint != null) PendingLaunches.take(nodeId) else null + if (launch != null) startLaunch(launch, lease, conn) + // The hand-off re-checks the screen: it may have left, or started a newer attach, + // since this one began. Then the stream is let go of instead of installed (A40). + main.post { + if (!slot.accept(ticket, lease)) return@post + val view = webView + val generation = page.currentGeneration + lateinit var actor: TerminalActions + actor = TerminalActions(graph.scope, s, onScrollView = { result, epoch, displayEpoch -> + main.post { + if (disposed || stopped || !slot.isCurrent(ticket) || stream !== s || + actions !== actor || webView !== view || !page.isCurrent(generation) || + actor.scrollEpoch != epoch) return@post + when (result) { + is TerminalScrollView.Result.History -> js("nt.showScrollView('${b64(result.json().toByteArray(Charsets.UTF_8))}',$displayEpoch)") + TerminalScrollView.Result.Input -> js("nt.closeScrollView(false,$displayEpoch)") + is TerminalScrollView.Result.Refused -> { js("nt.scrollFailed($displayEpoch)"); notice = result.message } + is TerminalScrollView.Result.Uncertain -> { js("nt.scrollFailed($displayEpoch)"); notice = result.message } + } + } + }) { slot.isCurrent(ticket) && stream === s } + actions = actor + js("nt.resumeScroll()") + attachedAt = System.currentTimeMillis() + state = TermState.Attached + // A session started for a launch types its own line: nothing to offer. + if (launch != null || managed != null) resumeOffer = null + if (cols > 0 && (cols != c || rows != r)) s.resize(cols, rows) + } + s to launch + } + if (managed == null && launch == null && slot.isCurrent(ticket)) afterAttach(s, conn, ticket) + } catch (e: NeedsRelayException) { + val msg = e.message ?: "This session opens through the relay." + main.post { + if (!slot.isCurrent(ticket)) return@post + retireOutput() + // No relay leg to open (remote access not set up or off, the route set to SSH only, + // or a computer added by its SSH address — A27): the same fact, with what is in + // the way for this computer instead of a relay offer. + state = e.refusal(session.relayLeg())?.let { TermState.Ended(it) } ?: TermState.RelayOffer(msg) + } + } catch (e: Exception) { + // Includes our own cancel on ON_STOP (while connecting, or in afterAttach): the ticket + // is stale by then, so nothing is shown. + main.post { + if (slot.isCurrent(ticket)) { + retireOutput() + managedReceiptBlocked = managed != null + state = TermState.Ended(e.message ?: "Couldn't open the terminal.") + } + } + } finally { + // Only this attach's own reference: a newer attach may have replaced it meanwhile. + main.post { if (attachJob === job) attachJob = null } + } + } + } + + /** + * A session this phone just started: type its launch line once the shell has settled, THEN put + * it on the canvas (registering first would let the desktop mount it cold and launch the agent + * too). Runs in the app scope, not in [attachJob], so neither ON_STOP nor leaving the screen + * drops it half-done (audit A40). + */ + private fun startLaunch(launch: LaunchRequest, lease: StreamLease, conn: HostConnection) { + val register: (suspend () -> Boolean)? = if (conn.capabilities.registerNode) { + { conn.registerNode(launch.projectId, NewNode(nodeId, launch.title, launch.agentId, launch.accountId)) } + } else null + PhoneLaunch.start(graph.scope, lease, launch.command, register) { outcome -> + // A refusal is an ANSWER (host-service: the session stays open, just unregistered): say + // so, instead of leaving a session no canvas shows (audit A14). + if (outcome == PhoneLaunch.Outcome.REFUSED) main.post { notice = UNREGISTERED_NOTICE } + session.refreshNow() + } + } + + private suspend fun afterAttach(s: TerminalStream, conn: HostConnection, ticket: Long) { + // A resume carried through a warm reattach is checked against what the computer says NOW: + // the listing from before the drop cannot show a CLI started in the pane meanwhile. (Read + // here only to decide on the re-list; the carry itself reads the offer again on main.) + if (!s.fresh && resumeOffer?.kind == ResumeOffer.Kind.RESUME) session.refreshNow() + val snap = session.snapshot.value + val status = snap.statusOf(nodeId) + // A Sleeping flag is only half the answer: a wake is offered while a SHELL owns the pane, the + // desktop's own gate (the A76 review: the flag outlives a CLI resumed outside the desktop's + // wake, this phone's included, and the line would land in that CLI as a prompt). + val pane = if (ResumeOffer.wantsPane(s.fresh, conn.kind, snap, nodeId)) conn.paneCommand(nodeId) else null + // A cold pane (a reboot) gets the agent's own resume, built like the desktop's cold restore + // (A15/A16); a Sleeping one over direct SSH gets the desktop's wake line, since nothing tells + // the desktop about an SSH attach (A76). Over the relay the attach itself asks the desktop to + // wake it, so nothing is offered there. Never typed unasked into a pane we cannot see. + main.post { + // Only for the attach the screen still shows: a newer one settles the offer itself. + if (!slot.isCurrent(ticket)) return@post + // The offer still on screen is carried: one the user answered or dismissed is not. + resumeOffer = ResumeOffer.afterAttach(s.fresh, conn.kind, snap, nodeId, carried = resumeOffer, paneCommand = pane) + resumeConn = conn + resumeSettled = true + } + // Reading a finished session on the phone is a READ: tell the computer (unread clears there, + // other phones archive the card), exactly what the SSH read-ack file does. + if (status?.state == AgentState.DONE) { + val ev = snap.status?.inbox?.events?.lastOrNull { it.nodeId == nodeId && it.kind == InboxKind.DONE && !it.resolved } + if (ev != null) { + runCatching { conn.ackRead(nodeId, ev.id) } + graph.hosts.markSeen(session.hostId, listOf(ev)) + } + } + } + + fun acceptResume() { + val offer = resumeOffer ?: return + // Not attached (the stream dropped under the offer), or a reattach is still settling it: keep + // it rather than spend it on nothing. The reattach keeps it or drops it (see [resumeOffer]). + if (!canResume) return + resumeOffer = null + val s = stream ?: return + if (offer.kind != ResumeOffer.Kind.WAKE) { + writeAfterScrollCancel(offer.keys, s) + return + } + val conn = resumeConn + // Re-asked at the tap (A76 and its review), the pane read LAST: the desktop may have woken the + // session since the offer appeared, or something else started a CLI in the pane, and a wake + // line typed into that CLI would arrive as a prompt. + graph.scope.launch { + val pane = conn?.paneCommand(nodeId) + val snap = session.snapshot.value + main.post { + // The screen moved on meanwhile (a reattach, or it left): not this tap's stream. + if (stream !== s) return@post + if (offer.stillOffered(snap, nodeId, pane)) { + writeAfterScrollCancel(offer.keys, s) + } else notice = ResumeOffer.WITHDRAWN + } + } + } + + fun dismissResume() { + resumeOffer = null + } + + fun fitHere() { + val s = stream ?: return + if (cols > 0) s.resize(cols, rows) + sizedElsewhere = null + } + + /** + * Send the input bar's text, as [InputBar.plan] says. An armed Ctrl applies to it (audit A34): the + * bar goes through xterm's bracketed paste, so the per-keystroke Ctrl in [Bridge.onInput] never saw + * a single character — arming Ctrl and sending `z` used to submit a literal `z` plus Enter. One + * character with a control byte is sent as that byte alone, with no Enter (^Z then Enter is not + * ^Z); anything else just disarms the chip and is sent as typed. + * + * Admission is not delivery: [onDelivered] clears the unchanged draft only after this same + * viewer confirms the explicit composed action. Refusal, stale views and uncertain writes keep + * it, and no delivery is retried. Raw keys, emulator replies and wheels use their original path. + */ + fun submit(text: String, enter: Boolean, modifier: CtrlModifier, + onCompleted: (ComposedInputResult, Boolean) -> Boolean, onDelivered: () -> Unit): Boolean { + fun refuse(message: String = "This terminal changed before Send. The draft was kept."): Boolean { + onCompleted(ComposedInputResult.refused(message), false) + return false + } + if (submitting) return refuse("Send is already pending. The draft was kept.") + val input = when (val send = InputBar.plan(attached, modifier.armed, text, enter)) { + InputBar.Send.NotAttached -> return refuse() + is InputBar.Send.Control -> ComposedInput.Control(send.bytes) + is InputBar.Send.Paste -> ComposedInput.Paste(send.text, send.enter) + } + val expected = stream ?: return refuse() + val actor = actions ?: return refuse() + val view = webView ?: return refuse() + if (!input.valid()) return refuse("Send is too large or invalid. The draft was kept.") + val completionPolicy = ComposedCompletion(modifier.revision) + submitting = true + actor.closeScrollView() + // Stop JS momentum before the actor discards queued scroll and awaits its in-flight call. + val stoppedScroll = ComposedPreparation.cancelMomentum { complete -> + view.evaluateJavascript("nt.cancelScroll();nt.closeScrollView()") { + complete(webView === view && stream === expected && actions === actor && attached) + } + } + graph.scope.launch { + var result = ComposedInputResult.uncertain() + try { + result = if (withTimeoutOrNull(3_000) { stoppedScroll.await() } == true) + actor.submit(input) else ComposedInputResult.refused() + } finally { + val completion = result + main.post { + submitting = false + val current = webView === view && stream === expected && actions === actor && attached + // Settle the captured entry even after disposal; never clear through a stale viewer + // or through an already retired/completed entry attempt. + if (onCompleted(completion, current)) + completionPolicy.complete(completion, current, ctrlModifier.revision, { ctrlArmed = false }, onDelivered) + } + } + } + return true + } + + /** A key-row key. Nothing while not [attached]: the key row shows disabled then (A41). */ + fun key(name: String) { + if (attached) js("nt.key('$name')") + } + + /** Raw bytes (the ^C/^D/^R/^L chips). Nothing while not [attached] (A41). */ + fun raw(data: String) { + if (attached) js("nt.raw('${b64(data.toByteArray(Charsets.UTF_8))}')") + } + + /** + * The ⌨ chip: bring up the soft keyboard to type straight into the terminal (audit A46). It used + * to run only `term.focus()` in the page, which cannot do that: Blink ignores focus() on the + * element that already has focus (the textarea, once the terminal was tapped), and the keyboard + * attaches to the focused Android view, which is the input bar's text field or nothing. The + * caller releases Compose's focus first, so that field lets go of the keyboard. Here, in order: + * the WebView takes Android's focus, the page moves its focus onto xterm's textarea + * (`nt.focusForKeyboard`, which blurs first), and the keyboard is asked for through + * InputMethodManager, which does not depend on the page having seen a touch since it loaded. + * + * That request waits for the next frame and then one more main-thread turn. The input method + * serves the newly focused view only once its focus change has been processed, and the text + * field that lost focus can hide the keyboard from a frame callback rather than at once + * (Compose's text input has queued its keyboard commands to the next frame): asked for before + * either, the keyboard would not come up, or would be hidden again straight away. None of this + * has run on a device. + */ + fun showKeyboard() { + val wv = webView ?: return + wv.requestFocus() + jsPage("nt.focusForKeyboard()") + Choreographer.getInstance().postFrameCallback { + wv.post { + // The view was replaced (renderer lost, A45) or the screen left meanwhile. + if (webView !== wv) return@post + val imm = wv.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(wv, InputMethodManager.SHOW_IMPLICIT) + } + } + } + + fun setFontSize(size: Int) { + graph.hosts.fontSize = size + jsPage("nt.setFontSize(${graph.hosts.fontSize})") + } + + /** The screen went to the background: detach (the session keeps running on the computer). */ + private var stopped = false + + fun onStop() { + if (disposed || stopped) return + stopped = true + retireOutput() + closeHistory() + jsPage("nt.suspendScroll()") + retireActions() + // Stops a connect or an approval wait; an attach already sent still lands, and is let go of + // by the hand-off (see attach(), A40). onStart begins its own. + attachJob?.cancel() + attachJob = null + // Detaches the stream, unless a launch still holds it: then once that launch is done (A40). + slot.leave() + webView?.onPause() + state = TermState.Connecting + } + + /** Back in the foreground: reattach where it left off (a first start is the normal attach). */ + fun onStart() { + if (disposed || !stopped) return + stopped = false + webView?.onResume() + // As from every other state, coming back reattaches, including after a view loss (A45). + if (stream == null && attachJob == null) attachWhenPageReady() + } + + fun dispose() { + disposed = true + retireOutput() + closeHistory() + retireActions() + // Detaches the stream (after a launch that still holds it, A40), and refuses every attach + // still on its way. attachJob is deliberately not cancelled: an attach that lands now is + // let go of by the hand-off, and a session it created still gets its launch. + slot.close() + webView?.let { destroyWebView(it, rendererAlive = true) } + webView = null + } + + companion object { + const val UNREGISTERED_NOTICE = + "The computer didn't add this session to the project, so it won't appear on the canvas. It keeps running; " + + "end it here when you are done, or find it in nodeterm's session list (the RAM pill) on the computer." + private val COPY_TOO_LARGE = + "Too large to copy: nodeterm copies up to %,d characters to the clipboard.".format(Osc52.MAX_TEXT_CHARS) + private const val COPY_REJECTED_SIZE = "Could not copy: too large for the clipboard." + private const val COPY_FAILED = "Could not copy to the clipboard." + private const val COPY_SHEET_NOT_READY = "The terminal is not ready yet." + private const val COPY_SHEET_FAILED = "Could not read the terminal's text." + private const val NOTHING_SELECTED = "Select the lines to copy first." + private val SHARE_TOO_LARGE = + "Too large to share: nodeterm shares up to %,d characters.".format(Osc52.MAX_TEXT_CHARS) + private const val SHARE_FAILED = "Could not share the text." + private const val INTENT_TOO_LARGE = "Too large to hand to another app." + private const val OPEN_FAILED = "Could not open the link." + private const val NO_APP = "No app on this phone can open that." + private fun b64(bytes: ByteArray): String = Base64.encodeToString(bytes, Base64.NO_WRAP) + private fun linesCopied(text: String): String { + val lines = text.count { it == '\n' } + 1 + return "Copied $lines line${if (lines == 1) "" else "s"}" + } + + /** A binder transaction refused for its size, rethrown by the framework somewhere in the chain. */ + private fun tooLarge(e: Throwable): Boolean = + generateSequence(e) { it.cause }.take(8).any { it is TransactionTooLargeException } + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalHistorySheet.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalHistorySheet.kt new file mode 100644 index 0000000000..b21a2c2ba5 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalHistorySheet.kt @@ -0,0 +1,101 @@ +package dev.nodeterm.android.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import dev.nodeterm.protocol.model.TerminalCopy +import dev.nodeterm.protocol.model.TerminalHistory +import kotlinx.coroutines.CancellationException + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun HistorySheet(controller: TerminalController) { + val ctx = LocalContext.current + val focus = LocalFocusManager.current + var query by remember { mutableStateOf("") } + var submitted by remember { mutableStateOf("") } + var request by remember { mutableStateOf(0) } + var result by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + var searching by remember { mutableStateOf(false) } + var selection by remember { mutableStateOf(TerminalCopy.Selection()) } + var cursor by remember { mutableStateOf(0) } + val list = rememberLazyListState() + fun submit() { if (TerminalHistory.validQuery(query)) { submitted = query; request++; focus.clearFocus() } } + LaunchedEffect(request) { + if (request == 0) return@LaunchedEffect + val ticket = request + val literal = submitted + searching = true; error = null; result = null; selection = TerminalCopy.Selection(); cursor = 0 + try { val answer = controller.searchHistory(literal); if (request == ticket) result = answer } + catch (cancelled: CancellationException) { throw cancelled } + catch (failed: Exception) { if (request == ticket) error = failed.message ?: "History search failed. Try again." } + finally { if (request == ticket) searching = false } + } + LaunchedEffect(result, cursor) { if (!result?.rows.isNullOrEmpty()) list.scrollToItem(cursor) } + BackHandler { controller.closeHistory() } + Column(Modifier.fillMaxSize().blockTouchesBelow().background(NtColors.panel).padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Find in terminal history", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium) + TextButton(onClick = { controller.closeHistory() }) { Text("Close") } + } + Text("Search all output the computer still retains. Match case; spaces are significant.", style = MaterialTheme.typography.bodySmall, color = NtColors.muted) + OutlinedTextField(query, onValueChange = { query = it }, label = { Text("Search history") }, singleLine = true, + modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { submit() })) + Row(verticalAlignment = Alignment.CenterVertically) { + Button(onClick = { submit() }, enabled = TerminalHistory.validQuery(query) && !searching) { Text("Search") } + if (searching) Text("Searching on the computer…", Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodySmall) + } + error?.let { Text(it, color = NtColors.attention, style = MaterialTheme.typography.bodySmall) } + val found = result + if (found != null) { + Text("${found.rows.size} matching lines in ${found.searchedLines} retained lines for “$submitted”", style = MaterialTheme.typography.bodySmall) + if (found.truncated) Text("Some matches are omitted. Use a more specific search.", style = MaterialTheme.typography.bodySmall, color = NtColors.attention) + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { cursor = (cursor + found.rows.size - 1) % found.rows.size }, enabled = found.rows.isNotEmpty()) { Text("Previous") } + TextButton(onClick = { cursor = (cursor + 1) % found.rows.size }, enabled = found.rows.isNotEmpty()) { Text("Next") } + TextButton(onClick = { selection = selection.all(found.rows.size) }, enabled = found.rows.isNotEmpty()) { Text("Select all") } + TextButton(onClick = { selection = selection.clear() }) { Text("Clear") } + } + Text("Tap matching lines to select them; long-press to extend the selection.", style = MaterialTheme.typography.bodySmall, color = NtColors.muted) + } + LazyColumn(Modifier.weight(1f).fillMaxWidth(), state = list) { + itemsIndexed(found?.rows.orEmpty(), key = { _, row -> row.line }) { index, row -> + val text = buildAnnotatedString { + append(row.text) + var start = row.text.indexOf(submitted) + var count = 0 + while (start >= 0 && count++ < TerminalCopy.MAX_SEARCH_MATCHES) { + addStyle(SpanStyle(background = NtColors.accent.copy(alpha = 0.4f)), start, start + submitted.length) + start = row.text.indexOf(submitted, start + submitted.length) + } + } + Column(Modifier.fillMaxWidth().background(if (index in selection.selected) NtColors.accent.copy(alpha = 0.25f) else NtColors.canvas) + .combinedClickable(onClick = { selection = selection.toggle(index); cursor = index }, onLongClick = { selection = selection.extendTo(index) }).padding(6.dp)) { + Text("Line ${row.line + 1}", style = MaterialTheme.typography.labelSmall, color = NtColors.muted) + Text(text, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + } + } + } + Button(onClick = { controller.copyHistory(ctx, found?.rows.orEmpty().map { it.text }, selection) }, enabled = selection.selected.isNotEmpty()) { Text("Copy selected lines") } + } +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt new file mode 100644 index 0000000000..eeb4668391 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt @@ -0,0 +1,571 @@ +package dev.nodeterm.android.ui + +import android.Manifest +import android.widget.Toast +import android.content.pm.PackageManager +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import dev.nodeterm.android.Navigator +import dev.nodeterm.android.NodetermApp +import dev.nodeterm.protocol.model.ExternalLink +import dev.nodeterm.protocol.model.OnScreen +import dev.nodeterm.protocol.model.TerminalCopy + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TerminalScreen(nav: Navigator, entryKey: String, hostId: String, nodeId: String, title: String) { + val context = LocalContext.current + val graph = NodetermApp.graph(context) + val session = remember(hostId) { graph.connections.session(hostId) } + val entry = remember(entryKey, hostId) { graph.terminalDrafts.entry(entryKey, hostId) } + val editor by entry.state.collectAsState() + val draft = editor.value + val controller = remember(hostId, nodeId, entry) { + TerminalController(graph, session, nodeId, entry.state.value.ctrl, entry::setCtrl) + } + // The draft with its cursor, not just its text (review of A59): the field's String overload keeps + // the previous cursor when the text is set from code, so after a dictation the cursor sat where it + // was before it (at 0 in an empty draft) and the next keystroke went into the middle of the words. + // The full editor and revision stay with this entry while another screen covers it. + // The mic (audit A59) writes what it hears into the draft and nothing else: it has no way to send. + // Its words go at the end of the draft, and so does the cursor, so typing after it continues there. + val dictation = remember(entry) { DictationController(context.applicationContext) { + entry.edit(cursorAtEnd(it)) + } } + val askMic = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) dictation.start(entry.state.value.value.text) else dictation.denied() + } + + // Attached and watching only while the screen is STARTED (audit A18): in the background the + // relay stream kept the desktop treating the session as watched (Eco shield, and the phone's + // size as a ceiling) and kept the radio busy. Stop detaches; start reattaches. + // While the pane shows, this session's Inbox events are in front of the user: the live refresh + // records them as seen instead of announcing them (A73). Only while it shows (the A73 review): + // the refresh asks controller.pane each time, so an overlay over the pane, or a terminal still + // connecting, records nothing. Registered before the watch starts, so its first listing knows. + LifecycleStartEffect(controller) { + val showing = session.onScreen.showNode(nodeId) { controller.pane } + session.startWatching() + controller.onStart() + onStopOrDispose { + controller.onStop() + session.stopWatching() + showing.close() + } + } + // The pane just came on screen: what it shows of the latest listing is seen now. That listing + // arrived while the terminal was connecting and left this session's events waiting (A73 review). + val pane = controller.pane + LaunchedEffect(controller, pane) { + if (pane == OnScreen.Pane.SHOWN) session.notePaneShown(nodeId) + } + DisposableEffect(controller) { + onDispose { controller.dispose() } + } + // Nothing listens while the screen is stopped, and the recognizer goes with the screen (A59). + LifecycleStartEffect(dictation) { + onStopOrDispose { dictation.cancel() } + } + DisposableEffect(dictation) { + onDispose { dictation.dispose() } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(title, maxLines = 1) }, + navigationIcon = { IconButton(onClick = { nav.pop() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + actions = { + TextButton(onClick = { controller.setFontSize(graph.hosts.fontSize - 1) }) { Text("A−") } + TextButton(onClick = { controller.setFontSize(graph.hosts.fontSize + 1) }) { Text("A+") } + } + ) + } + ) { padding -> + Box(Modifier.fillMaxSize().aboveKeyboard(padding)) { + Column(Modifier.fillMaxSize()) { + Box(Modifier.weight(1f).fillMaxWidth().background(NtColors.canvas)) { + // A new key = a new WebView: the old one was destroyed with its renderer (audit A45). + // None at all after a loss until the next attach is asked for (the review of A45). + if (controller.hasWebView) { + key(controller.webViewKey) { + AndroidView(factory = { ctx -> controller.createWebView(ctx) }, modifier = Modifier.fillMaxSize()) + } + } + when (val st = controller.state) { + TermState.Connecting -> Row( + Modifier.align(Alignment.Center).blockTouchesBelow().background(NtColors.panel, RoundedCornerShape(8.dp)).padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + CircularProgressIndicator(Modifier.padding(2.dp)) + Text("Opening terminal…") + } + is TermState.Ended -> Column( + Modifier.align(Alignment.Center).blockTouchesBelow().background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(st.message) + Button(onClick = { controller.reattach() }) { Text("Reattach") } + if (controller.managedReceiptBlocked) { + Text("The created session may still be running. Check it on the computer before discarding its saved attachment receipt.") + TextButton(onClick = { + try { if (controller.discardManagedReceipt()) nav.pop() } + catch (e: Exception) { Toast.makeText(context, e.message ?: "Couldn't save the receipt decision.", Toast.LENGTH_LONG).show() } + }) { Text("I checked the computer") } + } + } + is TermState.RelayOffer -> Column( + Modifier.align(Alignment.Center).blockTouchesBelow().background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(st.message) + Button(onClick = { controller.openThroughRelay() }) { Text("Open through the relay") } + } + is TermState.ViewLost -> Column( + Modifier.align(Alignment.Center).blockTouchesBelow().background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(st.message) + Button(onClick = { controller.reopenTerminal() }) { Text("Reopen terminal") } + } + is TermState.AwaitingApproval -> Column( + Modifier.align(Alignment.Center).blockTouchesBelow().background(NtColors.panel, RoundedCornerShape(8.dp)).padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Approve this phone on your computer") + Text(st.sas, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.headlineMedium, color = NtColors.accent) + Text( + "Approve only if the code on the computer matches this one.", + style = MaterialTheme.typography.bodySmall + ) + } + TermState.Attached -> Unit + } + controller.resumeOffer?.let { offer -> + Column( + Modifier.align(Alignment.TopCenter).blockTouchesBelow().fillMaxWidth().background(NtColors.panel2).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text(offer.message) + Text(offer.command, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { controller.acceptResume() }, enabled = controller.canResume) { Text(offer.button) } + OutlinedButton(onClick = { controller.dismissResume() }) { Text("Not now") } + } + } + } + val sendNotice = editor.sendNotice + (sendNotice?.message ?: controller.notice)?.let { msg -> + Row( + Modifier.align(Alignment.TopCenter).blockTouchesBelow().fillMaxWidth().background(NtColors.panel2).padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(msg, Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { + if (sendNotice != null) entry.dismissSendNotice(sendNotice) + else controller.notice = null + }) { Text("OK") } + } + } + Column(Modifier.align(Alignment.BottomCenter).blockTouchesBelow().fillMaxWidth()) { + // A tapped link, offered before anything opens (audit A32). + controller.linkOffer?.let { link -> LinkOffer(controller, link) } + controller.sizedElsewhere?.let { (c, r) -> + Row( + Modifier.fillMaxWidth().background(NtColors.panel2).padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("Sized to another screen (${c}×$r)", Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { controller.fitHere() }) { Text("Fit this screen") } + } + } + // Why a dictation ended without words (A59). + dictation.state.message?.let { msg -> + Row( + Modifier.fillMaxWidth().background(NtColors.panel2).padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(msg, Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { dictation.dismiss() }) { Text("OK") } + } + } + } + } + KeyRow(controller) + // The draft is cleared only once it was sent: while nothing is attached (connecting, + // disconnected, ended) Send is disabled and the keyboard's Send leaves the text in place, + // with the overlay above saying why (A41). Typing a draft meanwhile stays possible. + // Any change to the draft's text that is not dictation's ends a dictation (A59): its + // later results would bring back text that was sent or deleted. Moving the cursor, or the + // keyboard marking the word it composes, changes no text and ends nothing: the field + // reports those too, which its String overload did not. + val send: () -> Unit = { + if (controller.attached) { + val sent = entry.beginSend() + if (sent != null) controller.submit(sent.value.text, enter = true, modifier = sent.ctrl, + onCompleted = { result, current -> entry.completeSend(sent.attempt, result, current) }) { + if (entry.clearUnchangedDraft(sent.revision)) dictation.edited() + } + } + } + Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = draft, + onValueChange = { + val typed = it.text != entry.state.value.value.text + entry.edit(it) + if (typed) dictation.edited() + }, + modifier = Modifier.weight(1f), + placeholder = { Text(if (dictation.active) "Listening…" else "Type a command or a prompt") }, + maxLines = 4, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { send() }) + ) + // Hidden where the phone has no speech recognizer; the keyboard's own voice typing + // (where it has one) works in the field either way. Enabled while nothing is + // attached, like typing: a dictated draft waits for Send like a typed one. + if (dictation.available) { + IconButton(onClick = { + when { + dictation.active -> dictation.stop() + ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED -> dictation.start(entry.state.value.value.text) + // Asked on the first tap; the answer starts the dictation or says why not. + else -> askMic.launch(Manifest.permission.RECORD_AUDIO) + } + }) { + Icon( + MicIcon, + if (dictation.active) "Stop dictation" else "Dictate", + tint = if (dictation.active) NtColors.attention else LocalContentColor.current + ) + } + } + IconButton(onClick = send, enabled = controller.attached && editor.pendingSend == null && !controller.submitting) { Icon(Icons.AutoMirrored.Filled.Send, "Send") } + } + } + // Over the whole body, key row and input bar included: they have nothing to do while copying, + // and a touch on the sheet's text stays on the sheet (blockTouchesBelow, the A32 review). + controller.copySheet?.let { snapshot -> CopySheet(controller, snapshot) } + if (controller.historyOpen) HistorySheet(controller) + } + } +} + +/** + * A draft dictation wrote (review of A59): the cursor after its last character, where the heard words + * went ([dev.nodeterm.protocol.model.Dictation.join] appends them), and nothing selected or composing. + */ +private fun cursorAtEnd(text: String) = TextFieldValue(text, TextRange(text.length)) + +/** + * "Open ?" for a link tapped in the terminal (audit A32): the host, a choice, and the URL. The URL + * shows two lines until All shows the whole of it (review of A32). On a phone two lines are a few dozen + * characters, which a long OAuth URL fills, and so would one row's fragment of it, the very thing the + * matcher must never offer: only the whole URL says what Open opens. A long one scrolls inside the bar + * rather than covering the terminal. + */ +@Composable +private fun LinkOffer(controller: TerminalController, link: ExternalLink) { + val ctx = LocalContext.current + var whole by remember(link) { mutableStateOf(false) } + // Whether two lines cut the URL short, so All is offered only where it shows more. + var cut by remember(link) { mutableStateOf(false) } + Column(Modifier.fillMaxWidth().background(NtColors.panel2).padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Open ${link.host}?", + Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + TextButton(onClick = { controller.openLink(ctx, link) }) { Text("Open") } + TextButton(onClick = { controller.copyLink(ctx, link) }) { Text("Copy") } + IconButton(onClick = { controller.dismissLink() }) { Icon(Icons.Filled.Close, "Dismiss") } + } + Row(verticalAlignment = Alignment.Bottom) { + Text( + link.url, + Modifier.weight(1f).heightIn(max = 160.dp).verticalScroll(rememberScrollState()), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = NtColors.muted, + maxLines = if (whole) Int.MAX_VALUE else 2, + overflow = if (whole) TextOverflow.Clip else TextOverflow.Ellipsis, + onTextLayout = { if (!whole) cut = it.hasVisualOverflow } + ) + if (cut || whole) { + TextButton(onClick = { whole = !whole }) { Text(if (whole) "Less" else "All") } + } + } + } +} + +/** + * The Copy sheet (audit A32): the lines the terminal's buffer held when it was opened, to select and + * copy or share, and the links in them. tmux's mouse keeps xterm's own selection from running, and + * tmux's copy-mode is out of reach of a touch screen, so this is the phone's copy path. A tap selects + * or deselects a line; a long-press selects every line from the last one tapped. The selection logic is + * [TerminalCopy] (tested); this drawing is only type-checked. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CopySheet(controller: TerminalController, snapshot: TerminalCopy.Snapshot) { + val ctx = LocalContext.current + val focusManager = LocalFocusManager.current + var selection by remember(snapshot) { mutableStateOf(TerminalCopy.Selection()) } + var query by remember(snapshot) { mutableStateOf("") } + var matchIndex by remember(snapshot, query) { mutableStateOf(0) } + val search = remember(snapshot, query) { TerminalCopy.search(snapshot.lines, query) } + val matchesByLine = remember(search) { search.matches.groupBy { it.line } } + val activeMatch = search.matches.getOrNull(matchIndex) + BackHandler { controller.closeCopySheet() } + // The links come first in the list: the sheet opens on the screen's top line below them. + val linkRows = if (snapshot.links.isEmpty()) 0 else snapshot.links.size + 2 + val listState = rememberLazyListState(initialFirstVisibleItemIndex = linkRows + snapshot.firstVisible) + LaunchedEffect(search, matchIndex) { + activeMatch?.let { listState.scrollToItem(linkRows + it.line) } + } + Column(Modifier.fillMaxSize().blockTouchesBelow().background(NtColors.panel)) { + Row(Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text("Copy from the terminal", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium) + if (selection.selected.isEmpty()) { + TextButton(onClick = { selection = selection.all(snapshot.lines.size) }, enabled = snapshot.lines.isNotEmpty()) { Text("Select all") } + } else { + TextButton(onClick = { selection = selection.clear() }) { Text("Clear") } + } + IconButton(onClick = { controller.closeCopySheet() }) { Icon(Icons.Filled.Close, "Close") } + } + Text( + "Tap lines to select them. Long-press a line to select everything from the last line you tapped.", + Modifier.padding(horizontal = 12.dp), + style = MaterialTheme.typography.bodySmall, + color = NtColors.muted + ) + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text("Search captured output") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + trailingIcon = { + if (query.isNotEmpty()) IconButton(onClick = { query = "" }) { + Icon(Icons.Filled.Close, "Clear search") + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { + search.move(matchIndex, forward = true)?.let { matchIndex = it } + focusManager.clearFocus() + }) + ) + if (query.isNotBlank()) { + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + if (search.matches.isEmpty()) "No matches in captured output" + else "${matchIndex + 1} of ${search.matches.size}${if (search.truncated) "+" else ""} matches", + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = NtColors.muted + ) + TextButton(onClick = { search.move(matchIndex, forward = false)?.let { matchIndex = it } }, enabled = activeMatch != null) { Text("Previous") } + TextButton(onClick = { search.move(matchIndex, forward = true)?.let { matchIndex = it } }, enabled = activeMatch != null) { Text("Next") } + } + } + LazyColumn(Modifier.weight(1f).fillMaxWidth(), state = listState) { + if (snapshot.links.isNotEmpty()) { + item(key = "links") { SheetHeading("Links") } + items(snapshot.links, key = { "link:" + it.url }) { link -> + Row(Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(link.host, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + link.url, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = NtColors.muted, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + TextButton(onClick = { controller.openLink(ctx, link) }) { Text("Open") } + TextButton(onClick = { controller.copyLink(ctx, link) }) { Text("Copy") } + } + } + item(key = "text") { SheetHeading("Text") } + } + if (snapshot.lines.isEmpty()) { + item(key = "empty") { SheetHeading("The terminal shows no text.") } + } + itemsIndexed(snapshot.lines, key = { i, _ -> "line:$i" }) { i, line -> + val selected = i in selection.selected + val highlighted = buildAnnotatedString { + append(line.ifEmpty { " " }) + matchesByLine[i].orEmpty().forEach { match -> + addStyle(SpanStyle(background = NtColors.accent.copy(alpha = if (match == activeMatch) 0.65f else 0.25f)), match.start, match.end) + } + } + Text( + highlighted, + Modifier + .fillMaxWidth() + .background(if (selected) NtColors.accent.copy(alpha = 0.3f) else NtColors.canvas) + .combinedClickable(onClick = { selection = selection.toggle(i) }, onLongClick = { selection = selection.extendTo(i) }) + .padding(horizontal = 8.dp, vertical = 2.dp), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + color = NtColors.text + ) + } + } + val count = selection.selected.count { it in snapshot.lines.indices } + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + if (count == 0) "No lines selected" else "$count line${if (count == 1) "" else "s"} selected", + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall + ) + OutlinedButton(onClick = { controller.shareLines(ctx, selection) }, enabled = count > 0) { Text("Share") } + Button(onClick = { controller.copyLines(ctx, selection) }, enabled = count > 0, modifier = Modifier.padding(start = 8.dp)) { Text("Copy") } + } + } +} + +@Composable +private fun SheetHeading(text: String) { + Text( + text, + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = NtColors.muted + ) +} + +/** + * The keys a phone keyboard does not have, one tap each. Arrows honour the pane's cursor mode. The + * sending keys are disabled while nothing is attached (A41): they used to look sent and reach nothing. + * Copy (the Copy sheet), Ctrl (a modifier for the next key) and ⌨ (opens the keyboard) send nothing + * themselves. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun KeyRow(controller: TerminalController) { + val on = controller.attached + val focusManager = LocalFocusManager.current + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + // First, where the row is not scrolled out of sight (audit A32). Reads what the terminal shows, + // so it works attached or not. The input bar lets go of focus first: its keyboard would sit + // over the sheet. + KeyChip("Copy") { + focusManager.clearFocus() + controller.openCopySheet() + } + KeyChip("Find") { + focusManager.clearFocus() + controller.openHistory() + } + FilterChip(selected = controller.ctrlArmed, onClick = { controller.ctrlArmed = !controller.ctrlArmed }, label = { Text("Ctrl") }) + KeyChip("Esc", on) { controller.key("esc") } + KeyChip("Tab", on) { controller.key("tab") } + KeyChip("⇧Tab", on) { controller.key("stab") } + KeyChip("↑", on) { controller.key("up") } + KeyChip("↓", on) { controller.key("down") } + KeyChip("←", on) { controller.key("left") } + KeyChip("→", on) { controller.key("right") } + KeyChip("⏎", on) { controller.key("enter") } + KeyChip("⇧⏎", on) { controller.key("nl") } + KeyChip("^C", on) { controller.raw("\u0003") } + KeyChip("^D", on) { controller.raw("\u0004") } + KeyChip("^R", on) { controller.raw("\u0012") } + KeyChip("^L", on) { controller.raw("\u000c") } + KeyChip("Home", on) { controller.key("home") } + KeyChip("End", on) { controller.key("end") } + KeyChip("PgUp", on) { controller.key("pgup") } + KeyChip("PgDn", on) { controller.key("pgdn") } + // The input bar's field lets go of focus first, or it keeps the keyboard (audit A46). + KeyChip("⌨") { + focusManager.clearFocus() + controller.showKeyboard() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun KeyChip(label: String, enabled: Boolean = true, onClick: () -> Unit) { + FilterChip(selected = false, onClick = onClick, enabled = enabled, label = { Text(label, fontFamily = FontFamily.Monospace) }) +} diff --git a/android/app/src/main/kotlin/dev/nodeterm/android/ui/Theme.kt b/android/app/src/main/kotlin/dev/nodeterm/android/ui/Theme.kt new file mode 100644 index 0000000000..270e86c786 --- /dev/null +++ b/android/app/src/main/kotlin/dev/nodeterm/android/ui/Theme.kt @@ -0,0 +1,97 @@ +package dev.nodeterm.android.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nodeterm.protocol.model.SessionBucket + +/** The desktop's dark palette (styles.css `:root`, Apple system colours). */ +object NtColors { + val accent = Color(0xFF0A84FF) + val working = Color(0xFFD97757) + val attention = Color(0xFFFF453A) + val warning = Color(0xFFFF9F0A) + val success = Color(0xFF32D74B) + val muted = Color(0xFF8E8E93) + val panel = Color(0xFF1C1C1E) + val panel2 = Color(0xFF2C2C2E) + val canvas = Color(0xFF000000) + val text = Color(0xFFE6E6E6) +} + +private val scheme = darkColorScheme( + primary = NtColors.accent, + onPrimary = Color.White, + secondary = NtColors.working, + background = NtColors.canvas, + onBackground = NtColors.text, + surface = NtColors.panel, + onSurface = NtColors.text, + surfaceVariant = NtColors.panel2, + onSurfaceVariant = Color(0xFFB0B0B5), + error = NtColors.attention +) + +@Composable +fun NodetermTheme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = scheme, content = content) +} + +/** Parse a stored `#rrggbb` node/project colour; anything else falls back. */ +fun parseHex(hex: String?, fallback: Color = NtColors.muted): Color { + val h = hex?.trim()?.removePrefix("#") ?: return fallback + if (h.length != 6) return fallback + val v = h.toLongOrNull(16) ?: return fallback + return Color(0xFF000000 or v) +} + +@Composable +fun ColorDot(color: Color, size: Int = 10) { + Box(Modifier.size(size.dp).clip(CircleShape).background(color)) +} + +/** The RUNNING / NEEDS YOU / SLEEPING badges the canvas header shows. */ +@Composable +fun StatusBadge(bucket: SessionBucket?) { + val (label, color) = when (bucket) { + SessionBucket.RUNNING -> "RUNNING" to NtColors.working + SessionBucket.NEEDS_YOU -> "NEEDS YOU" to NtColors.attention + SessionBucket.SLEEPING -> "SLEEPING" to NtColors.muted + else -> return + } + Text( + label, + color = color, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(color.copy(alpha = 0.16f)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) +} + +/** Short relative age ("now", "5m", "3h", "2d") for session rows and inbox cards. */ +fun relativeAge(ts: Long, now: Long = System.currentTimeMillis()): String { + if (ts <= 0) return "" + val s = (now - ts) / 1000 + return when { + s < 45 -> "now" + s < 3600 -> "${s / 60}m" + s < 86_400 -> "${s / 3600}h" + else -> "${s / 86_400}d" + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..3c04f1c173 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..f3ac264f8d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..e3531ced22 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0B0B0F + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..1ee4079b37 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + nodeterm + Needs you + An agent needs an approval or an answer + Finished + An agent finished its turn + Sending answers + Shown on Android 11 and lower while an answer given from a notification is sent + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..964958b773 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000000..82f41abd1e --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000000..612e580599 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000000..a13c906576 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("com.android.application") version "8.10.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000000..5f4cff3710 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..1b33c55baa Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..d4081da476 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000000..23d15a9367 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000000..db3a6ac207 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts new file mode 100644 index 0000000000..8845721015 --- /dev/null +++ b/android/protocol/build.gradle.kts @@ -0,0 +1,100 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") version "2.2.0" + `java-library` +} + +group = "dev.nodeterm" +version = "0.1.0" + +java { + // Android's D8 accepts Java 17 bytecode; building with a newer JDK is fine, targeting it is not. + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +dependencies { + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + api("com.squareup.okhttp3:okhttp:4.12.0") + // Direct-SSH transport (LAN pairing). sshj brings its own BouncyCastle + EdDSA; on Android the + // app re-registers the full BouncyCastle provider at startup (see NodetermApp). + api("com.hierynomus:sshj:0.39.0") + // sshj declares EdDSA runtime-only; the phone's Ed25519 identity is built with it directly. + api("net.i2p.crypto:eddsa:0.3.0") + + testImplementation(kotlin("test")) + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") + // A real SSH server for the direct-SSH transport tests (runs /bin/sh + a sandboxed tmux). + testImplementation("org.apache.sshd:sshd-core:2.14.0") + testRuntimeOnly("org.slf4j:slf4j-simple:2.0.16") +} + +tasks.test { + useJUnitPlatform() + // Interop tests drive this repo's TypeScript host code through node. They locate the repo root + // from here and skip themselves (with a reason) when node or node_modules are missing. + systemProperty("nodeterm.repoRoot", rootDir.resolve("../..").canonicalPath) + // Files some tests run or read that are not on the test classpath: the node drivers, the app's + // terminal page (TerminalJs*Test), the app's sources (the source pins in TerminalKeyboardChipTest, + // SettingsLeaveTest and R8RulesTest), the release build's R8 rules, its build type and the CI + // workflow that runs R8 (R8RulesTest), the CI config GradleCiCoverageTest checks: that workflow + // (every job that runs Gradle is in it), the Dependabot config, the root build's settings and the + // build scripts whose dependencies it matches against the Dependabot groups (the app's is named + // above), and the contributor docs and the wrapper's Gradle version ContributorDocsTest checks + // against each other, and the audit whose finding ids DeviceChecklistDocsTest checks the device + // checklist against. + // Declared so a change to one of them re-runs the tests instead of leaving them "up to date". + inputs.dir("src/test/interop").withPathSensitivity(PathSensitivity.RELATIVE) + // The desktop code the interop fixture bundles (the same dirs as android.yml's path filter). Without + // them a desktop change, say to the projects.list assembly or the agent-status mirror the relay + // tests read (audit A64), left the tests "up to date" locally and was never run against. + for (dir in listOf("src/core", "src/shared", "src/main", "src/session-host")) { + inputs.dir(rootDir.resolve("../../$dir")).withPathSensitivity(PathSensitivity.RELATIVE) + } + // The custom-profile fixture uses the actual Server path resolver/platform without starting + // Server services. These imported sources must also invalidate the locally cached test task. + inputs.file(rootDir.resolve("../../src/server/config.ts")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../../src/server/platform-server.ts")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../../src/server/proxy-trust.ts")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../../tsconfig.json")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.dir(rootDir.resolve("../app/src/main/assets/terminal")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.dir(rootDir.resolve("../app/src/main/kotlin")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../app/proguard-rules.pro")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../app/build.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) + // The app's manifest (BackupRulesTest, DictationTest) and the type-check's build, whose android-all + // pin DictationTest reads the recognizer's error codes from. + inputs.file(rootDir.resolve("../app/src/main/AndroidManifest.xml")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../tools/typecheck/build.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../../.github/workflows/android.yml")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../../.github/dependabot.yml")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../settings.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../build.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("build.gradle.kts")).withPathSensitivity(PathSensitivity.RELATIVE) + inputs.file(rootDir.resolve("../gradle/wrapper/gradle-wrapper.properties")).withPathSensitivity(PathSensitivity.RELATIVE) + for (doc in listOf( + "CONTRIBUTING.md", "docs/android.md", "android/README.md", "android/tools/typecheck/README.md", + "docs/android-audit-2026-09.md", + )) { + inputs.file(rootDir.resolve("../../$doc")).withPathSensitivity(PathSensitivity.RELATIVE) + } + // R8RulesTest reads the jars this module ships to the app (its runtime classpath, not the test one) + // for the classes they reference that Android lacks (audit A37). + val shippedJars: FileCollection = configurations.runtimeClasspath.get() + inputs.files(shippedJars).withPropertyName("shippedJars").withNormalizer(ClasspathNormalizer::class.java) + jvmArgumentProviders.add(CommandLineArgumentProvider { listOf("-Dnodeterm.runtimeClasspath=${shippedJars.asPath}") }) + systemProperty("org.slf4j.simpleLogger.defaultLogLevel", "warn") + testLogging { + events("failed", "skipped") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} diff --git a/android/protocol/scripts/gen-crypto-vectors.cjs b/android/protocol/scripts/gen-crypto-vectors.cjs new file mode 100644 index 0000000000..d09bebc2a2 --- /dev/null +++ b/android/protocol/scripts/gen-crypto-vectors.cjs @@ -0,0 +1,64 @@ +// Regenerate src/test/resources/crypto-vectors.json from the DESKTOP's own crypto: +// tweetnacl (the exact library src/main/remote/e2ee.ts uses) + node:crypto HKDF. +// node android/protocol/scripts/gen-crypto-vectors.cjs (from the repo root, after npm ci) +// Deterministic: every input comes from a seeded SHA-256 counter, so a rerun is byte-identical. +const path = require('path') +const crypto = require('crypto') +const repo = path.resolve(__dirname, '../../..') +const nacl = require(path.join(repo, 'node_modules/tweetnacl')) + +let counter = 0 +function det(n) { + const out = Buffer.alloc(n) + let off = 0 + while (off < n) { + const h = crypto.createHash('sha256').update(`nodeterm-android-vectors-${counter++}`).digest() + h.copy(out, off, 0, Math.min(32, n - off)) + off += 32 + } + return new Uint8Array(out) +} +const b64 = (u) => Buffer.from(u).toString('base64') + +const keypairs = [] +for (let i = 0; i < 5; i++) { + const sk = det(32) + const kp = nacl.box.keyPair.fromSecretKey(sk) + keypairs.push({ secretKey: b64(sk), publicKey: b64(kp.publicKey) }) +} +const before = [] +for (let i = 0; i < 4; i++) { + const a = nacl.box.keyPair.fromSecretKey(det(32)) + const b = nacl.box.keyPair.fromSecretKey(det(32)) + const shared = nacl.box.before(b.publicKey, a.secretKey) + const sas = (() => { + const h = nacl.hash(shared) + const n = ((h[0] << 24) | (h[1] << 16) | (h[2] << 8) | h[3]) >>> 0 + const code = (n % 1_000_000).toString().padStart(6, '0') + return `${code.slice(0, 3)} ${code.slice(3)}` + })() + before.push({ aSecret: b64(a.secretKey), bPublic: b64(b.publicKey), shared: b64(shared), sas }) +} +const secretbox = [] +for (const len of [0, 1, 15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 129, 200, 1000, 4099]) { + const key = det(32) + const nonce = det(24) + const msg = det(len) + secretbox.push({ key: b64(key), nonce: b64(nonce), msg: b64(msg), box: b64(nacl.secretbox(msg, nonce, key)) }) +} +const hkdf = [] +for (let i = 0; i < 3; i++) { + const base = det(32) + const hostNonce = det(16) + const clientNonce = det(16) + const salt = Buffer.concat([Buffer.from(hostNonce), Buffer.from(clientNonce)]) + const out = crypto.hkdfSync('sha256', base, salt, Buffer.from('nodeterm-relay-session-v2'), 32) + hkdf.push({ base: b64(base), hostNonce: b64(hostNonce), clientNonce: b64(clientNonce), sessionKey: b64(new Uint8Array(out)) }) +} +const hostIds = keypairs.map((k) => ({ + publicKey: k.publicKey, + hostId: crypto.createHash('sha256').update(Buffer.from(k.publicKey, 'base64')).digest('base64url').slice(0, 22) +})) +const out = { generatedBy: 'android/protocol/scripts/gen-crypto-vectors.cjs', tweetnacl: require(path.join(repo, 'node_modules/tweetnacl/package.json')).version, keypairs, before, secretbox, hkdf, hostIds } +require('fs').writeFileSync(path.join(__dirname, '../src/test/resources/crypto-vectors.json'), JSON.stringify(out, null, 2) + '\n') +console.log('wrote', secretbox.length, 'secretbox vectors') diff --git a/android/protocol/settings.gradle.kts b/android/protocol/settings.gradle.kts new file mode 100644 index 0000000000..86ba9bd424 --- /dev/null +++ b/android/protocol/settings.gradle.kts @@ -0,0 +1,18 @@ +// The phone protocol as a standalone JVM build. It is deliberately NOT an Android module: every +// byte it puts on the wire (NaCl box, relay handshake, host RPC, pairing) is tested here on a +// plain JVM, including interop runs against this repo's own TypeScript host code — none of which +// needs the Android SDK. The app build (../settings.gradle.kts) consumes it via includeBuild. +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +rootProject.name = "protocol" diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/NaclBox.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/NaclBox.kt new file mode 100644 index 0000000000..f4a6c0d86d --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/NaclBox.kt @@ -0,0 +1,150 @@ +package dev.nodeterm.protocol.crypto + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** A NaCl box keypair (Curve25519). The secret key is the raw 32 bytes tweetnacl stores. */ +class BoxKeyPair(val publicKey: ByteArray, val secretKey: ByteArray) { + init { + require(publicKey.size == 32 && secretKey.size == 32) { "box keys are 32 bytes" } + } + + val publicKeyB64: String get() = B64.encode(publicKey) + + companion object { + fun generate(random: SecureRandom = SecureRandom()): BoxKeyPair { + val sk = ByteArray(32).also(random::nextBytes) + return fromSecretKey(sk) + } + + /** Recompute the public half, exactly as tweetnacl's `box.keyPair.fromSecretKey`. */ + fun fromSecretKey(secretKey: ByteArray): BoxKeyPair { + require(secretKey.size == 32) { "secret key must be 32 bytes" } + val pk = ByteArray(32) + TweetNacl.scalarmultBase(pk, secretKey) + return BoxKeyPair(pk, secretKey.copyOf()) + } + } +} + +/** Standard (padded) base64 — what the desktop's `publicKeyToB64` / `Buffer.toString('base64')` emit. */ +object B64 { + fun encode(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + + /** Strict decode: returns null instead of throwing on malformed input. */ + fun decode(text: String): ByteArray? = try { + Base64.getDecoder().decode(text.trim()) + } catch (_: IllegalArgumentException) { + null + } + + fun encodeUrl(bytes: ByteArray): String = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + + fun decodeUrl(text: String): ByteArray? = try { + Base64.getUrlDecoder().decode(text.trim().trimEnd('=')) + } catch (_: IllegalArgumentException) { + null + } +} + +/** + * The desktop's `src/main/remote/e2ee.ts`, one function per function. Every name here maps to the + * TypeScript one it mirrors so a reader can check them side by side. + */ +object E2ee { + private val random = SecureRandom() + + /** `publicKeyFromB64`: strict — anything that is not exactly 32 bytes is refused. */ + fun publicKeyFromB64(b64: String): ByteArray { + val key = B64.decode(b64) ?: throw IllegalArgumentException("Invalid public key: not base64") + require(key.size == 32) { "Invalid public key: expected 32 bytes, got ${key.size}" } + return key + } + + /** `deriveSharedKey`: the STABLE per-device-pair ECDH precompute (`nacl.box.before`). */ + fun deriveSharedKey(theirPubB64: String, ourSecret: ByteArray): ByteArray = + TweetNacl.boxBefore(publicKeyFromB64(theirPubB64), ourSecret) + + fun deriveSharedKey(theirPub: ByteArray, ourSecret: ByteArray): ByteArray = + TweetNacl.boxBefore(theirPub, ourSecret) + + /** `randomSessionNonce`: 16 random bytes, exchanged in the handshake (NOT the 24-byte box nonce). */ + fun randomSessionNonce(): ByteArray = ByteArray(16).also(random::nextBytes) + + /** + * `deriveSessionKey` = HKDF-SHA256(ikm = baseShared, salt = hostNonce ‖ clientNonce, + * info = "nodeterm-relay-session-v2", L = 32). Host nonce FIRST in both roles. + */ + fun deriveSessionKey(baseShared: ByteArray, hostNonce: ByteArray, clientNonce: ByteArray): ByteArray = + Hkdf.sha256( + ikm = baseShared, + salt = hostNonce + clientNonce, + info = "nodeterm-relay-session-v2".toByteArray(Charsets.UTF_8), + length = 32 + ) + + /** `encrypt`: `nonce(24) ‖ box.after(plain)`. */ + fun encrypt(plain: ByteArray, shared: ByteArray): ByteArray { + val nonce = ByteArray(TweetNacl.NONCE_BYTES).also(random::nextBytes) + return nonce + TweetNacl.secretbox(plain, nonce, shared) + } + + /** `decrypt`: null on malformed input or a failed MAC — never throws. */ + fun decrypt(box: ByteArray, shared: ByteArray): ByteArray? { + if (box.size < TweetNacl.NONCE_BYTES + TweetNacl.OVERHEAD_BYTES) return null + val nonce = box.copyOfRange(0, TweetNacl.NONCE_BYTES) + return TweetNacl.secretboxOpen(box.copyOfRange(TweetNacl.NONCE_BYTES, box.size), nonce, shared) + } + + /** + * `sasFromSharedKey`: SHA-512 of the BASE key (never the session key), first four bytes read + * big-endian as an unsigned 32-bit int, mod 1,000,000, zero-padded, "NNN NNN". + */ + fun sasFromSharedKey(shared: ByteArray): String { + val h = MessageDigest.getInstance("SHA-512").digest(shared) + val n = ((h[0].toLong() and 0xff) shl 24) or + ((h[1].toLong() and 0xff) shl 16) or + ((h[2].toLong() and 0xff) shl 8) or + (h[3].toLong() and 0xff) + val code = (n % 1_000_000L).toString().padStart(6, '0') + return "${code.substring(0, 3)} ${code.substring(3)}" + } + + /** + * `hostIdFromPublicKey` (relay-id.ts): base64url(sha256(raw 32-byte pub)).slice(0, 22) — the + * relay broker room both ends register under. + */ + fun hostIdFromPublicKey(pub: ByteArray): String = + B64.encodeUrl(MessageDigest.getInstance("SHA-256").digest(pub)).take(22) + + fun hostIdFromPublicKeyB64(b64: String): String = hostIdFromPublicKey(publicKeyFromB64(b64)) +} + +/** RFC 5869 HKDF over HMAC-SHA256 (matches Node's `hkdfSync('sha256', …)`). */ +object Hkdf { + fun sha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + require(length in 1..(255 * 32)) { "bad HKDF length" } + val prk = hmac(if (salt.isEmpty()) ByteArray(32) else salt, ikm) + val out = ByteArray(length) + var t = ByteArray(0) + var pos = 0 + var counter = 1 + while (pos < length) { + t = hmac(prk, t + info + byteArrayOf(counter.toByte())) + val n = minOf(t.size, length - pos) + System.arraycopy(t, 0, out, pos, n) + pos += n + counter++ + } + return out + } + + private fun hmac(key: ByteArray, data: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(data) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/TweetNacl.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/TweetNacl.kt new file mode 100644 index 0000000000..8120c1e614 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/crypto/TweetNacl.kt @@ -0,0 +1,376 @@ +package dev.nodeterm.protocol.crypto + +/** + * The subset of TweetNaCl the phone protocol needs: `crypto_box` (Curve25519 + XSalsa20-Poly1305) + * and its pieces. A faithful port of the public-domain TweetNaCl reference (the same code the + * desktop runs as `tweetnacl` 1.0.3), so every box this produces opens there and vice versa — + * `TweetNaclTest` pins that against vectors generated by the desktop's own library. + * + * Why a port rather than a provider: X25519 via `KeyAgreement("XDH")` only exists on Android 12+, + * no platform provider exposes HSalsa20 (which `box.before` needs), and a hand-assembled + * BouncyCastle chain would still need HSalsa20 written out. Two hundred lines that are checked + * byte-for-byte against the other end are the smaller risk. + * + * Wire shape of `box.after` (tweetnacl-js `secretbox`): `mac(16) ‖ ciphertext`. The desktop's + * `e2ee.encrypt` prepends the 24-byte nonce — see [NaclBox]. + */ +internal object TweetNacl { + const val KEY_BYTES = 32 + const val NONCE_BYTES = 24 + const val ZERO_BYTES = 32 + const val BOX_ZERO_BYTES = 16 + const val OVERHEAD_BYTES = ZERO_BYTES - BOX_ZERO_BYTES + + private val ZERO16 = ByteArray(16) + private val NINE = ByteArray(32).also { it[0] = 9 } + private val SIGMA = "expand 32-byte k".toByteArray(Charsets.US_ASCII) + private val GF_121665 = longArrayOf(0xDB41, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + + // ---- Salsa20 core --------------------------------------------------------------------- + + private fun ld32(x: ByteArray, i: Int): Int = + (x[i].toInt() and 0xff) or + ((x[i + 1].toInt() and 0xff) shl 8) or + ((x[i + 2].toInt() and 0xff) shl 16) or + ((x[i + 3].toInt() and 0xff) shl 24) + + private fun st32(x: ByteArray, j: Int, u: Int) { + var v = u + for (i in 0 until 4) { + x[j + i] = (v and 0xff).toByte() + v = v ushr 8 + } + } + + private fun rotl(x: Int, c: Int): Int = (x shl c) or (x ushr (32 - c)) + + private fun core(out: ByteArray, inp: ByteArray, inpOff: Int, k: ByteArray, c: ByteArray, h: Boolean) { + val w = IntArray(16) + val x = IntArray(16) + val y = IntArray(16) + val t = IntArray(4) + for (i in 0 until 4) { + x[5 * i] = ld32(c, 4 * i) + x[1 + i] = ld32(k, 4 * i) + x[6 + i] = ld32(inp, inpOff + 4 * i) + x[11 + i] = ld32(k, 16 + 4 * i) + } + for (i in 0 until 16) y[i] = x[i] + for (i in 0 until 20) { + for (j in 0 until 4) { + for (m in 0 until 4) t[m] = x[(5 * j + 4 * m) % 16] + t[1] = t[1] xor rotl(t[0] + t[3], 7) + t[2] = t[2] xor rotl(t[1] + t[0], 9) + t[3] = t[3] xor rotl(t[2] + t[1], 13) + t[0] = t[0] xor rotl(t[3] + t[2], 18) + for (m in 0 until 4) w[4 * j + (j + m) % 4] = t[m] + } + for (m in 0 until 16) x[m] = w[m] + } + if (h) { + for (i in 0 until 16) x[i] += y[i] + for (i in 0 until 4) { + x[5 * i] -= ld32(c, 4 * i) + x[6 + i] -= ld32(inp, inpOff + 4 * i) + } + for (i in 0 until 4) { + st32(out, 4 * i, x[5 * i]) + st32(out, 16 + 4 * i, x[6 + i]) + } + } else { + for (i in 0 until 16) st32(out, 4 * i, x[i] + y[i]) + } + } + + private fun coreSalsa20(out: ByteArray, inp: ByteArray, k: ByteArray, c: ByteArray) = + core(out, inp, 0, k, c, false) + + fun coreHSalsa20(out: ByteArray, inp: ByteArray, inpOff: Int, k: ByteArray, c: ByteArray) = + core(out, inp, inpOff, k, c, true) + + /** XOR `b` bytes of `m` (or zeros when `m` is null) with the Salsa20 stream for nonce[nOff..nOff+8). */ + private fun streamSalsa20Xor( + c: ByteArray, cposStart: Int, m: ByteArray?, mposStart: Int, bStart: Long, + n: ByteArray, nOff: Int, k: ByteArray + ) { + var b = bStart + if (b == 0L) return + val z = ByteArray(16) + val x = ByteArray(64) + var cpos = cposStart + var mpos = mposStart + for (i in 0 until 8) z[i] = n[nOff + i] + while (b >= 64) { + coreSalsa20(x, z, k, SIGMA) + for (i in 0 until 64) c[cpos + i] = ((if (m != null) m[mpos + i].toInt() else 0) xor x[i].toInt()).toByte() + var u = 1 + for (i in 8 until 16) { + u += z[i].toInt() and 0xff + z[i] = (u and 0xff).toByte() + u = u ushr 8 + } + b -= 64 + cpos += 64 + if (m != null) mpos += 64 + } + if (b > 0) { + coreSalsa20(x, z, k, SIGMA) + for (i in 0 until b.toInt()) c[cpos + i] = ((if (m != null) m[mpos + i].toInt() else 0) xor x[i].toInt()).toByte() + } + } + + private fun stream(c: ByteArray, d: Long, n: ByteArray, k: ByteArray) { + val s = ByteArray(32) + coreHSalsa20(s, n, 0, k, SIGMA) + streamSalsa20Xor(c, 0, null, 0, d, n, 16, s) + } + + private fun streamXor(c: ByteArray, m: ByteArray, d: Long, n: ByteArray, k: ByteArray) { + val s = ByteArray(32) + coreHSalsa20(s, n, 0, k, SIGMA) + streamSalsa20Xor(c, 0, m, 0, d, n, 16, s) + } + + // ---- Poly1305 (reference 17-limb form) ------------------------------------------------ + + private val MINUS_P = intArrayOf(5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 252) + + private fun add1305(h: IntArray, c: IntArray) { + var u = 0 + for (j in 0 until 17) { + u += h[j] + c[j] + h[j] = u and 255 + u = u ushr 8 + } + } + + private fun onetimeauth(out: ByteArray, outPos: Int, m: ByteArray, mposStart: Int, nStart: Long, k: ByteArray) { + val x = IntArray(17) + val r = IntArray(17) + val h = IntArray(17) + val c = IntArray(17) + val g = IntArray(17) + for (j in 0 until 16) r[j] = k[j].toInt() and 0xff + r[3] = r[3] and 15; r[4] = r[4] and 252; r[7] = r[7] and 15; r[8] = r[8] and 252 + r[11] = r[11] and 15; r[12] = r[12] and 252; r[15] = r[15] and 15 + var mpos = mposStart + var n = nStart + while (n > 0) { + for (j in 0 until 17) c[j] = 0 + var j = 0 + while (j < 16 && j < n) { + c[j] = m[mpos + j].toInt() and 0xff + j++ + } + c[j] = 1 + mpos += j + n -= j + add1305(h, c) + for (i in 0 until 17) { + var acc = 0 + for (jj in 0 until 17) { + acc += h[jj] * (if (jj <= i) r[i - jj] else 320 * r[i + 17 - jj]) + } + x[i] = acc + } + for (i in 0 until 17) h[i] = x[i] + var u = 0 + for (jj in 0 until 16) { + u += h[jj] + h[jj] = u and 255 + u = u ushr 8 + } + u += h[16] + h[16] = u and 3 + u = 5 * (u ushr 2) + for (jj in 0 until 16) { + u += h[jj] + h[jj] = u and 255 + u = u ushr 8 + } + u += h[16] + h[16] = u + } + for (j in 0 until 17) g[j] = h[j] + add1305(h, MINUS_P) + val s = -(h[16] ushr 7) + for (j in 0 until 17) h[j] = h[j] xor (s and (g[j] xor h[j])) + for (j in 0 until 16) c[j] = k[j + 16].toInt() and 0xff + c[16] = 0 + add1305(h, c) + for (j in 0 until 16) out[outPos + j] = h[j].toByte() + } + + private fun verify16(x: ByteArray, xi: Int, y: ByteArray, yi: Int): Boolean { + var d = 0 + for (i in 0 until 16) d = d or ((x[xi + i].toInt() xor y[yi + i].toInt()) and 0xff) + return d == 0 + } + + // ---- secretbox ------------------------------------------------------------------------- + + /** tweetnacl-js `secretbox(msg, nonce, key)`: returns `mac(16) ‖ ciphertext`. */ + fun secretbox(msg: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray { + require(nonce.size == NONCE_BYTES) { "bad nonce size" } + require(key.size == KEY_BYTES) { "bad key size" } + val m = ByteArray(ZERO_BYTES + msg.size) + System.arraycopy(msg, 0, m, ZERO_BYTES, msg.size) + val c = ByteArray(m.size) + streamXor(c, m, m.size.toLong(), nonce, key) + onetimeauth(c, 16, c, 32, (m.size - 32).toLong(), c) + return c.copyOfRange(BOX_ZERO_BYTES, c.size) + } + + /** tweetnacl-js `secretbox.open`: null on a short box or a failed MAC. */ + fun secretboxOpen(box: ByteArray, nonce: ByteArray, key: ByteArray): ByteArray? { + if (nonce.size != NONCE_BYTES || key.size != KEY_BYTES) return null + val c = ByteArray(BOX_ZERO_BYTES + box.size) + System.arraycopy(box, 0, c, BOX_ZERO_BYTES, box.size) + if (c.size < 32) return null + val x = ByteArray(32) + stream(x, 32, nonce, key) + val tag = ByteArray(16) + onetimeauth(tag, 0, c, 32, (c.size - 32).toLong(), x) + if (!verify16(c, 16, tag, 0)) return null + val m = ByteArray(c.size) + streamXor(m, c, c.size.toLong(), nonce, key) + return m.copyOfRange(ZERO_BYTES, m.size) + } + + // ---- Curve25519 ----------------------------------------------------------------------- + + private fun car25519(o: LongArray) { + for (i in 0 until 16) { + o[i] += 1L shl 16 + val c = o[i] shr 16 + if (i < 15) o[i + 1] += c - 1 else o[0] += 38 * (c - 1) + o[i] -= c shl 16 + } + } + + private fun sel25519(p: LongArray, q: LongArray, b: Int) { + val c = (b - 1).toLong().inv() + for (i in 0 until 16) { + val t = c and (p[i] xor q[i]) + p[i] = p[i] xor t + q[i] = q[i] xor t + } + } + + private fun pack25519(o: ByteArray, n: LongArray) { + val m = LongArray(16) + val t = n.copyOf() + car25519(t) + car25519(t) + car25519(t) + for (j in 0 until 2) { + m[0] = t[0] - 0xffed + for (i in 1 until 15) { + m[i] = t[i] - 0xffff - ((m[i - 1] shr 16) and 1) + m[i - 1] = m[i - 1] and 0xffff + } + m[15] = t[15] - 0x7fff - ((m[14] shr 16) and 1) + val b = ((m[15] shr 16) and 1).toInt() + m[14] = m[14] and 0xffff + sel25519(t, m, 1 - b) + } + for (i in 0 until 16) { + o[2 * i] = (t[i] and 0xff).toByte() + o[2 * i + 1] = (t[i] shr 8).toByte() + } + } + + private fun unpack25519(o: LongArray, n: ByteArray) { + for (i in 0 until 16) o[i] = (n[2 * i].toLong() and 0xff) + ((n[2 * i + 1].toLong() and 0xff) shl 8) + o[15] = o[15] and 0x7fff + } + + private fun add(o: LongArray, a: LongArray, b: LongArray) { + for (i in 0 until 16) o[i] = a[i] + b[i] + } + + private fun sub(o: LongArray, a: LongArray, b: LongArray) { + for (i in 0 until 16) o[i] = a[i] - b[i] + } + + private fun mul(o: LongArray, a: LongArray, b: LongArray) { + val t = LongArray(31) + for (i in 0 until 16) for (j in 0 until 16) t[i + j] += a[i] * b[j] + for (i in 0 until 15) t[i] += 38 * t[i + 16] + for (i in 0 until 16) o[i] = t[i] + car25519(o) + car25519(o) + } + + private fun sq(o: LongArray, a: LongArray) = mul(o, a, a) + + private fun inv25519(o: LongArray, i: LongArray) { + val c = i.copyOf() + for (a in 253 downTo 0) { + sq(c, c) + if (a != 2 && a != 4) mul(c, c, i) + } + for (a in 0 until 16) o[a] = c[a] + } + + /** X25519: `q = n · p`. `n` is clamped here, exactly as the reference does. */ + fun scalarmult(q: ByteArray, n: ByteArray, p: ByteArray) { + val z = ByteArray(32) + for (i in 0 until 31) z[i] = n[i] + z[31] = ((n[31].toInt() and 127) or 64).toByte() + z[0] = (z[0].toInt() and 248).toByte() + val x = LongArray(16) + unpack25519(x, p) + val a = LongArray(16) + val b = x.copyOf() + val c = LongArray(16) + val d = LongArray(16) + val e = LongArray(16) + val f = LongArray(16) + a[0] = 1 + d[0] = 1 + for (i in 254 downTo 0) { + val r = ((z[i ushr 3].toInt() and 0xff) ushr (i and 7)) and 1 + sel25519(a, b, r) + sel25519(c, d, r) + add(e, a, c) + sub(a, a, c) + add(c, b, d) + sub(b, b, d) + sq(d, e) + sq(f, a) + mul(a, c, a) + mul(c, b, e) + add(e, a, c) + sub(a, a, c) + sq(b, a) + sub(c, d, f) + mul(a, c, GF_121665) + add(a, a, d) + mul(c, c, a) + mul(a, d, f) + mul(d, b, x) + sq(b, e) + sel25519(a, b, r) + sel25519(c, d, r) + } + val x16 = a.copyOf() + val x32 = c.copyOf() + inv25519(x32, x32) + mul(x16, x16, x32) + pack25519(q, x16) + } + + fun scalarmultBase(q: ByteArray, n: ByteArray) = scalarmult(q, n, NINE) + + /** tweetnacl-js `box.before(theirPublic, ourSecret)`. */ + fun boxBefore(publicKey: ByteArray, secretKey: ByteArray): ByteArray { + require(publicKey.size == 32) { "bad public key size" } + require(secretKey.size == 32) { "bad secret key size" } + val s = ByteArray(32) + scalarmult(s, secretKey, publicKey) + val k = ByteArray(32) + coreHSalsa20(k, ZERO16, 0, s, SIGMA) + return k + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitDiff.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitDiff.kt new file mode 100644 index 0000000000..a77e947478 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitDiff.kt @@ -0,0 +1,79 @@ +package dev.nodeterm.protocol.git + +/** + * A unified diff as the phone shows it: each line with the kind that colours it. The desktop's + * `git.diff` answers git's own text (`git diff -- `, `--cached` for a staged file, and + * `--no-index /dev/null ` for an untracked one), with no size cap of its own, so the view + * keeps the first [MAX_LINES] lines and says how many it left out. For an unmerged path git prints + * a COMBINED diff (`diff --cc`, `@@@` hunks, one marker column per side), which reads the same way. + */ +data class GitDiff(val lines: List, val omitted: Int) { + enum class Kind { + /** `diff --git`, `index`, `---`/`+++` file names, mode and rename lines, "Binary files … differ". */ + META, + /** `@@ -a,b +c,d @@`. */ + HUNK, + ADD, + DEL, + CONTEXT, + /** `\ No newline at end of file`. */ + NOTE + } + + data class Line(val kind: Kind, val text: String) + + val isEmpty: Boolean get() = lines.isEmpty() + + companion object { + const val MAX_LINES = 4_000 + + /** Longer lines (a minified file) are cut, so one line cannot hold the view. */ + const val MAX_LINE_CHARS = 2_000 + + /** + * Classify [text]. A `+++`/`---` line is a file name only BEFORE the first hunk of a file: + * inside a hunk it is an added line that starts with `++` (or a removed one with `--`), which + * a naive prefix test would colour as a header. + * + * A hunk header's `@` count says how many marker columns its lines carry: `@@` one, a + * combined diff's `@@@` two (one per side of a merge). A line with a `-` in any column is not + * in the result (removed); one with a `+` in any column is new against some side (added), + * which is how git itself colours a combined diff. So ` +ours` in a conflict is an added + * line, not context. + */ + fun parse(text: String, maxLines: Int = MAX_LINES): GitDiff { + if (text.isEmpty()) return GitDiff(emptyList(), 0) + val raw = text.removeSuffix("\n").split('\n') + val out = ArrayList(minOf(raw.size, maxLines)) + var inHunk = false + var columns = 1 + for (rawLine in raw.take(maxLines)) { + val line = rawLine.removeSuffix("\r") + val kind = when { + line.startsWith("diff ") -> { + inHunk = false + Kind.META + } + line.startsWith("@@") -> { + inHunk = true + columns = (line.takeWhile { it == '@' }.length - 1).coerceAtLeast(1) + Kind.HUNK + } + !inHunk -> Kind.META + line.startsWith("\\") -> Kind.NOTE + else -> { + val markers = line.take(columns) + when { + '-' in markers -> Kind.DEL + '+' in markers -> Kind.ADD + else -> Kind.CONTEXT + } + } + } + val shown = if (line.length > MAX_LINE_CHARS) line.take(MAX_LINE_CHARS) + " …" else line + out += Line(kind, shown) + } + return GitDiff(out, (raw.size - maxLines).coerceAtLeast(0)) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitReplies.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitReplies.kt new file mode 100644 index 0000000000..1376a385b7 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/GitReplies.kt @@ -0,0 +1,208 @@ +package dev.nodeterm.protocol.git + +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.objects +import dev.nodeterm.protocol.model.J.s +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * One changed file, as the desktop's `GitService.status` reports it (`GitFileChange` in + * src/shared/types.ts). [status] is git's one letter: `M`, `A`, `D`, `R`, `C`, … or `U`. + * + * The desktop sends `U` for two different things: an UNTRACKED file (porcelain `??`, in `changes` + * only) and a path git reports as UNMERGED (a `U` on either side of porcelain `XY`, sent in both + * lists). [GitReplies.status] takes the unmerged paths out into [GitStatus.conflicts], so in the + * [GitStatus] it builds a `U` left in [GitStatus.changes] is an untracked file and nothing else. + */ +data class GitFileChange(val path: String, val status: String, val added: Int, val deleted: Int) { + val untracked: Boolean get() = status == "U" +} + +/** + * A path git reports as unmerged: a merge, rebase, cherry-pick or stash pop that conflicted on it, + * the phone's own Pull included. The desktop's status has no field for this; it sends the path in + * BOTH of its lists, porcelain `X` as the staged entry ([ours]) and `Y` as the working-tree one + * ([theirs]). Unresolved, the file may hold conflict markers, so it is never offered as an untracked + * file (whose diff is the whole file as additions) nor staged from the phone (`git add` marks it + * resolved, markers and all). + */ +data class GitConflict(val path: String, val ours: String, val theirs: String) { + /** Porcelain `XY`: `UU`, `AA`, `DD`, `AU`, `UA`, `DU` or `UD`. */ + val code: String get() = ours + theirs + + /** git's own wording for [code] (`git status`'s "both modified:" and so on). */ + val description: String + get() = when (code) { + "UU" -> "both modified" + "AA" -> "both added" + "DD" -> "both deleted" + "AU" -> "added by us" + "UA" -> "added by them" + "DU" -> "deleted by us" + "UD" -> "deleted by them" + else -> "unmerged" + } + + companion object { + /** + * Whether porcelain [x] (index side) and [y] (working-tree side) of one path mean "unmerged". + * From git-status(1): the unmerged states are exactly `DD AU UD UA DU AA UU`. A `U` on either + * side occurs only there, and so do `AA` and `DD` (an added file's working-tree letter is one + * of ` MTD`, and a file deleted from the index has none). + */ + fun isUnmerged(x: String, y: String): Boolean = x == "U" || y == "U" || (x == y && (x == "A" || x == "D")) + } +} + +/** + * `git.status` (`GitStatus` in src/shared/types.ts). A folder that is not a repository answers + * `hasRepo: false` with nothing else filled in. [staged] is what a commit would take, and [changes] + * holds the working-tree changes AND the untracked files, as the desktop sends them but WITHOUT the + * unmerged paths, which are in [conflicts] instead (see [GitConflict]); [unstaged] and [untracked] + * split [changes]. + */ +data class GitStatus( + val hasRepo: Boolean, + val repoName: String, + /** The current branch, or `HEAD` when detached. */ + val branch: String, + val ahead: Int, + val behind: Int, + /** Any remote at all (a fork may have only `upstream`). */ + val hasRemote: Boolean, + /** The current branch tracks an upstream (it has been pushed once). */ + val hasUpstream: Boolean, + val staged: List, + val changes: List, + /** Unmerged paths, in the desktop's order. A commit is refused by git while there is one. */ + val conflicts: List = emptyList() +) { + val unstaged: List get() = changes.filter { !it.untracked } + val untracked: List get() = changes.filter { it.untracked } + val clean: Boolean get() = staged.isEmpty() && changes.isEmpty() && conflicts.isEmpty() +} + +/** + * What a write verb (`git.stage|unstage|commit|push|pull`) answered: `GitResult` in + * src/shared/types.ts. A git command that FAILED on the computer is an answer, not an RPC error: + * `ok: false` with git's own message (`stderr`), which the screen shows as it is. Only a refused + * request (no git bridge, a folder outside the shared roots) is an RPC error, i.e. a + * [dev.nodeterm.protocol.host.HostException] carrying the host's sentence. + */ +data class GitResult(val ok: Boolean, val message: String) + +/** One commit of `git.history` (`GitHistoryItem`, src/shared/git-history-types.ts). */ +data class GitCommit( + val id: String, + /** The short hash the desktop shows; the first 7 characters of [id] when it sent none. */ + val shortId: String, + val subject: String, + val author: String?, + /** Author time in ms since the epoch (the desktop converts git's seconds). */ + val timestampMs: Long?, + /** Branch and tag names pointing at this commit. */ + val refs: List +) + +/** `git.history` (`GitHistoryResult`): the newest commits of the current branch first. */ +data class GitHistory( + val commits: List, + /** More commits exist past the host's limit (50 by default). */ + val hasMore: Boolean, + val currentRef: String?, + val remoteRef: String?, + val hasIncomingChanges: Boolean, + val hasOutgoingChanges: Boolean +) + +/** + * Reads the bodies the desktop's jailed git bridge sends back (`handleGit` in + * src/main/remote/host-service.ts, which hands each verb to the real `GitService` in + * src/core/git-service.ts and responds with its return value). A reply that is not the shape that + * verb returns is null: the caller says so instead of showing an empty repository that is not + * there. Field by field the reads are tolerant, like the rest of the phone's parsing. The status + * fields the screen does not use (`branches`, `remoteBranches`, `hasOrigin`, `ghAvailable`, + * `ghAuthed`) are left unread: the phone switches no branches and publishes nothing. + */ +object GitReplies { + fun status(body: JsonElement?): GitStatus? { + val o = J.obj(body) ?: return null + val hasRepo = o.b("hasRepo") ?: return null + val staged = o.objects("staged").mapNotNull(::change) + val changes = o.objects("changes").mapNotNull(::change) + val conflicts = conflictsOf(staged, changes) + val conflicted = conflicts.mapTo(HashSet()) { it.path } + return GitStatus( + hasRepo = hasRepo, + repoName = o.s("repoName") ?: "", + branch = o.s("branch") ?: "", + ahead = o.l("ahead")?.toInt()?.coerceAtLeast(0) ?: 0, + behind = o.l("behind")?.toInt()?.coerceAtLeast(0) ?: 0, + hasRemote = o.b("hasRemote") == true, + hasUpstream = o.b("hasUpstream") == true, + staged = staged.filter { it.path !in conflicted }, + changes = changes.filter { it.path !in conflicted }, + conflicts = conflicts + ) + } + + /** + * The unmerged paths among what the desktop sent. An unmerged path is in BOTH lists (`X` and `Y` + * are never blank for one), so a path only in `changes` is never one: that is where an untracked + * `U` lives. A `U` only in `staged` cannot come from the desktop's parsing; it is still unmerged. + */ + private fun conflictsOf(staged: List, changes: List): List { + val working = changes.associateBy { it.path } + return staged.distinctBy { it.path }.mapNotNull { s -> + val y = working[s.path]?.status ?: "" + if (GitConflict.isUnmerged(s.status, y)) GitConflict(s.path, s.status, y) else null + } + } + + private fun change(c: JsonObject): GitFileChange? { + val path = c.s("path")?.takeIf { it.isNotEmpty() } ?: return null + return GitFileChange( + path = path, + status = c.s("status")?.takeIf { it.isNotEmpty() } ?: "M", + added = c.l("added")?.toInt()?.coerceAtLeast(0) ?: 0, + deleted = c.l("deleted")?.toInt()?.coerceAtLeast(0) ?: 0 + ) + } + + /** `git.diff` answers the diff text itself (a JSON string); empty when there is nothing to show. */ + fun diff(body: JsonElement?): String? = J.str(body) + + fun result(body: JsonElement?): GitResult? { + val o = J.obj(body) ?: return null + val ok = o.b("ok") ?: return null + return GitResult(ok, o.s("message") ?: "") + } + + fun history(body: JsonElement?): GitHistory? { + val o = J.obj(body) ?: return null + if (o["items"] !is JsonArray) return null + return GitHistory( + commits = o.objects("items").mapNotNull { c -> + val id = c.s("id")?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + GitCommit( + id = id, + shortId = c.s("displayId")?.takeIf { it.isNotEmpty() } ?: id.take(7), + subject = c.s("subject") ?: "", + author = c.s("author")?.takeIf { it.isNotBlank() }, + timestampMs = c.l("timestamp")?.takeIf { it > 0 }, + refs = c.objects("references").mapNotNull { it.s("name")?.takeIf(String::isNotEmpty) } + ) + }, + hasMore = o.b("hasMore") == true, + currentRef = o.o("currentRef")?.s("name"), + remoteRef = o.o("remoteRef")?.s("name"), + hasIncomingChanges = o.b("hasIncomingChanges") == true, + hasOutgoingChanges = o.b("hasOutgoingChanges") == true + ) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/SourceControl.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/SourceControl.kt new file mode 100644 index 0000000000..3822faef0f --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/git/SourceControl.kt @@ -0,0 +1,193 @@ +package dev.nodeterm.protocol.git + +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.HostUnansweredException +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.model.ProjectInfo +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive + +/** + * The project's source control over the desktop's typed, jailed git bridge (audit A29): the `git.*` + * verbs `createHostHandlers` serves (src/main/remote/host-service.ts), each one handed to the real + * `GitService` in src/core/git-service.ts. There is no free-form git: the phone can read the status, + * a file's diff and the recent history, stage and unstage files, commit what is staged, and push or + * pull the current branch, which is what the bridge serves and nothing more. + * + * [cwd] is the project's folder as `projects.list` names it. The desktop refuses any other folder + * ("cwd is outside the shared project roots."), and a desktop without the bridge refuses every verb + * ("git is not served on this host."); both arrive as a [HostException] carrying that sentence, for + * the screen to show as it is. + * + * Direct SSH serves the same typed verbs against listed folders on that computer, including + * projects driven there by another desktop. A third-machine project is still refused. + */ +class SourceControl(private val conn: HostConnection, val cwd: String) { + suspend fun status(): GitStatus = GitReplies.status(conn.git(GitVerb.STATUS, cwd)) ?: throw unreadable() + + /** + * The diff of [file] on one side: [staged] = the index against HEAD (`--cached`), else the working + * tree against the index, or the whole file for an untracked one. + */ + suspend fun diff(file: GitFileChange, staged: Boolean): GitDiff = diff(file.path, staged, untracked = !staged && file.untracked) + + /** + * An unmerged path's working tree, as plain `git diff -- ` shows it: the combined diff with + * the conflict markers for a file both sides changed, git's "* Unmerged path" line otherwise. + * Never the untracked form, which would show the conflicted file as a new one. + */ + suspend fun diff(conflict: GitConflict): GitDiff = diff(conflict.path, staged = false, untracked = false) + + private suspend fun diff(path: String, staged: Boolean, untracked: Boolean): GitDiff { + val body = conn.git( + GitVerb.DIFF, cwd, + mapOf( + "path" to JsonPrimitive(path), + "staged" to JsonPrimitive(staged), + "untracked" to JsonPrimitive(untracked) + ) + ) + return GitDiff.parse(GitReplies.diff(body) ?: throw unreadable()) + } + + suspend fun stage(paths: List): GitResult = + write(GitVerb.STAGE, "Staging", mapOf("paths" to JsonArray(paths.map(::JsonPrimitive)))) + + suspend fun unstage(paths: List): GitResult = + write(GitVerb.UNSTAGE, "Unstaging", mapOf("paths" to JsonArray(paths.map(::JsonPrimitive)))) + + /** Commits what is STAGED (the desktop adds nothing on its own). */ + suspend fun commit(message: String): GitResult = write(GitVerb.COMMIT, "The commit", mapOf("message" to JsonPrimitive(message))) + + /** `git push`; a branch with no upstream is pushed to `origin` with `-u` by the desktop. */ + suspend fun push(): GitResult = write(GitVerb.PUSH, "The push") + + suspend fun pull(): GitResult = write(GitVerb.PULL, "The pull") + + suspend fun history(): GitHistory = GitReplies.history(conn.git(GitVerb.HISTORY, cwd)) ?: throw unreadable() + + /** + * A write whose request went out and got no answer (it timed out, or the connection dropped + * while it waited) did not necessarily fail: the desktop runs it with no limit, and a commit's + * hooks or a push to a slow remote may still be running, or may have finished, there. That is + * said instead of a bare "RPC timed out", and the screen reads the status again to show which. + */ + private suspend fun write(verb: GitVerb, what: String, args: Map = emptyMap()): GitResult { + val body = try { + conn.git(verb, cwd, args) + } catch (e: HostUnansweredException) { + throw HostUnansweredException(unanswered(what)) + } + return GitReplies.result(body) ?: throw unreadable() + } + + private fun unreadable() = HostException("The computer's answer to a source-control request could not be read.") + + companion object { + /** What a write that got no answer says; [what] names it ("The commit"). */ + fun unanswered(what: String): String = + "The computer did not answer. $what may still be running there, or may have finished." + + /** + * Why a commit cannot be made yet, or null when it can. git refuses a commit while a path is + * unmerged; the desktop commits only what is staged and refuses an empty message ("Commit + * message is empty."). The button says so first. + */ + fun commitBlocker(status: GitStatus?, message: String): String? = when { + status == null || !status.hasRepo -> "There is no repository to commit to." + status.conflicts.isNotEmpty() -> "Resolve the conflicts on the computer first." + status.staged.isEmpty() -> "Stage the changes to commit first." + message.isBlank() -> "Write a commit message." + else -> null + } + + /** Why Push and Pull cannot run, or null when they can. */ + fun syncBlocker(status: GitStatus?): String? = when { + status == null || !status.hasRepo -> "There is no repository." + !status.hasRemote -> "This repository has no remote to push to or pull from." + else -> null + } + + /** + * What the screen says after a write verb: git's own message as the desktop returned it, as an + * error when the command failed there. A success that said nothing (staging) says nothing. + */ + fun outcome(result: GitResult): Outcome? = when { + !result.ok -> Outcome(error = true, text = result.message.ifBlank { "git failed on the computer." }) + result.message.isBlank() -> null + else -> Outcome(error = false, text = result.message) + } + + /** + * Runs one write, then [reload]s the status WHATEVER happened to it, and answers what the + * screen says. Reading again after a failure is the point: a push that git refused still + * moved nothing, but a commit that got no answer ([HostUnansweredException]: its hooks + * outlasted the wait, the connection dropped) may have landed on the computer all the same, + * and a screen still showing "N staged" would then say the opposite of what is there. The + * write's own outcome wins; a failed re-read is said only when the write had nothing to say. + */ + suspend fun writeThenReload(label: String, write: suspend () -> GitResult?, reload: suspend () -> Unit): Outcome? { + val said = try { + write()?.let(::outcome) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Outcome(error = true, text = e.message ?: "$label failed.") + } + val reread = try { + reload() + null + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Outcome(error = true, text = e.message ?: "The status could not be read again.") + } + return said ?: reread + } + } + + data class Outcome(val error: Boolean, val text: String) +} + +/** + * Whether a project's source control can be opened from the phone at all, and if not, why — decided + * before any request, so a control can say it instead of failing on a tap (audit A29). + */ +object SourceControlGate { + sealed interface Availability { + /** Open it: [cwd] is the project's folder on the computer. */ + data class Available(val cwd: String) : Availability + data class Unavailable(val reason: String) : Availability + } + + /** + * [leg] is where `git.*` goes right now (`LegRouting.route(Capability.GIT, …)`). + * + * An SSH project of the desktop is refused here rather than by the host. The listing does name + * its folder (`ssh.remoteCwd`), but that is a path on ANOTHER machine, which the desktop's own + * Source Control reaches over its ControlMaster; the bridge's jail is this computer's local + * project folders, which normally do not include it, so the request would only be refused. + */ + fun of(project: ProjectInfo?, leg: LegRouting.Leg): Availability { + if (project == null) return Availability.Unavailable("This project is no longer on the computer.") + // Direct SSH can use its folder HERE; this computer's relay cannot own that canvas (A27). + if (project.drivenRemotely && leg != LegRouting.Leg.Primary) return Availability.Unavailable(LegRouting.drivenElsewhere(Capability.GIT)) + project.sshTarget?.let { + return Availability.Unavailable( + "This project's folder is on $it, which the computer reaches over SSH. Source control from the " + + "phone covers folders on the computer itself; use Source Control in nodeterm on the computer." + ) + } + val cwd = project.cwd?.takeIf { it.isNotBlank() } + ?: return Availability.Unavailable("This project has no folder on the computer, so it has no repository.") + return when (leg) { + is LegRouting.Leg.Unavailable -> Availability.Unavailable(leg.reason) + LegRouting.Leg.Primary, LegRouting.Leg.Relay -> Availability.Available(cwd) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ComposedCompletion.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ComposedCompletion.kt new file mode 100644 index 0000000000..af8d0efdc7 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ComposedCompletion.kt @@ -0,0 +1,25 @@ +package dev.nodeterm.protocol.host + +/** Main-thread completion of one Send; admission, uncertainty and later generations clear nothing. */ +class ComposedCompletion(private val armedRevision: Long) { + private var completed = false + + fun complete(result: ComposedInputResult, current: Boolean, ctrlRevision: Long, + clearCtrl: () -> Unit, onDelivered: () -> Unit): Boolean { + if (completed) return false + completed = true + if (!current || result.status != ComposedInputResult.Status.DELIVERED) return false + if (ctrlRevision == armedRevision) clearCtrl() + onDelivered() + return true + } + + companion object { + /** Revision detects an edit away and back to the same text while Send is awaiting receipt. */ + fun clearUnchangedDraft(sentRevision: Long, currentRevision: Long, clear: () -> Unit): Boolean { + if (sentRevision != currentRevision) return false + clear() + return true + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ComposedInput.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ComposedInput.kt new file mode 100644 index 0000000000..07b1e3b55a --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ComposedInput.kt @@ -0,0 +1,32 @@ +package dev.nodeterm.protocol.host + +/** An explicit Send from the input bar, never a wheel, emulator reply or ordinary keystroke. */ +sealed interface ComposedInput { + data class Paste(val text: String, val enter: Boolean) : ComposedInput + data class Control(val text: String) : ComposedInput + + /** Same paste normalization as the pinned xterm clipboard path; tmux owns framing. */ + fun normalized(): ComposedInput = when (this) { + is Paste -> copy(text = text.replace(Regex("\\r?\\n"), "\r").replace("\u001b", "").replace("\u009b", "")) + is Control -> this + } + + fun valid(): Boolean = when (this) { + is Paste -> !text.contains('\u0000') && text.toByteArray(Charsets.UTF_8).size <= MAX_BYTES + is Control -> text.length == 1 && (text[0].code in 0..31 || text[0].code == 127) + } + + val size: Int get() = when (this) { is Paste -> text.length; is Control -> 1 } + + companion object { const val MAX_BYTES = 262_144 } +} + +/** Delivery to this pane, not acknowledgement that its application ran the command. */ +data class ComposedInputResult(val status: Status, val message: String? = null) { + enum class Status { DELIVERED, REFUSED, UNCERTAIN } + companion object { + val DELIVERED = ComposedInputResult(Status.DELIVERED) + fun refused(message: String = "This terminal changed before Send. The draft was kept.") = ComposedInputResult(Status.REFUSED, message) + fun uncertain(message: String = "Send could not be confirmed. The draft was kept; check the terminal before sending it again.") = ComposedInputResult(Status.UNCERTAIN, message) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ComposedPreparation.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ComposedPreparation.kt new file mode 100644 index 0000000000..9af3e7feee --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ComposedPreparation.kt @@ -0,0 +1,22 @@ +package dev.nodeterm.protocol.host + +import kotlinx.coroutines.CompletableDeferred + +/** The UI provider may throw synchronously instead of acknowledging momentum cancellation. */ +object ComposedPreparation { + fun cancelMomentum(evaluate: (complete: (Boolean) -> Unit) -> Unit): CompletableDeferred { + val ready = CompletableDeferred() + val lock = Any() + var returned = false + var early: Boolean? = null + try { evaluate { value -> synchronized(lock) { + if (returned) ready.complete(value) else early = value + } } } + catch (_: RuntimeException) { ready.complete(false) } + finally { synchronized(lock) { + returned = true + early?.let { ready.complete(it) } + } } + return ready + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ConnectionUsers.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ConnectionUsers.kt new file mode 100644 index 0000000000..12aa520cb3 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ConnectionUsers.kt @@ -0,0 +1,76 @@ +package dev.nodeterm.protocol.host + +/** + * Who is using one computer's connection (the review of audit A25): the screens that show the + * computer ([watch]) and the background jobs that run on it ([hold]), the periodic Inbox check and an + * answer given from a notification. + * + * They all share ONE connection, and the relay leg opened next to it. A background job closes it when + * it ends, so the socket is not held open for a screen nobody looks at; but only the LAST user may do + * that. Each job used to close it whenever no screen watched, under another job still sending on it: + * two answers to one computer a few seconds apart, or an answer and the background check, and the + * first to finish cut the other off, which then reported its answer unconfirmed (or never sent it). + * + * A computer's own screen leaves the connection for a quick return. All computers instead requests + * closure when its last watcher leaves, after any in-flight listing or background job finishes. The app's HostSession owns one of these per computer; this is the + * counting, kept pure so the JVM tests it. + */ +class ConnectionUsers { + private var watchers = 0 + private var holders = 0 + private var closeWhenIdle: (() -> Unit)? = null + + /** A screen shows the computer now. */ + val watched: Boolean + @Synchronized get() = watchers > 0 + + /** A screen started showing the computer. True when it is the first (start re-listing). */ + @Synchronized + fun watch(): Boolean { + // A new screen supersedes a stopped screen's deferred close, even if its cancelled listing + // has not returned yet. That old listing must never close the new screen's connection. + closeWhenIdle = null + return ++watchers == 1 + } + + /** A screen stopped showing the computer. True when none is left (stop re-listing). */ + @Synchronized + fun unwatch(close: (() -> Unit)? = null): Boolean { + watchers = (watchers - 1).coerceAtLeast(0) + if (watchers == 0 && close != null) { + closeWhenIdle = close + closeIfIdle() + } + return watchers == 0 + } + + /** Called under this object's lock; closure detaches the old socket without suspending. */ + private fun closeIfIdle() { + if (holders == 0 && watchers == 0) { + val closing = closeWhenIdle + closeWhenIdle = null + closing?.invoke() + } + } + + /** + * Runs [block] as a background user of the connection. When it ends (normally, by an error or by + * cancellation), [close] runs if nothing else uses the connection: no other background user and + * no screen. A null [close] protects a foreground listing without changing quick-return policy; + * it still delivers a close requested by a stopped All computers screen or a background job. [close] runs under this object's lock and must not block or suspend: a user that + * arrives meanwhile is either counted before the decision, or finds the connection already closed + * and dials its own, never one closed under it. + */ + suspend fun hold(close: (() -> Unit)?, block: suspend () -> T): T { + synchronized(this) { holders++ } + try { + return block() + } finally { + synchronized(this) { + holders-- + if (watchers == 0 && close != null) closeWhenIdle = close + closeIfIdle() + } + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ForegroundRefresh.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ForegroundRefresh.kt new file mode 100644 index 0000000000..52e2cf42af --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ForegroundRefresh.kt @@ -0,0 +1,72 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.host.RelayApprovalGate.Trigger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * One computer's foreground listing loop, shared by every visible screen. The app supplies its real + * refresh and 8-second delay. All computers starts with AUTO; opening an individual computer starts + * with USER. Subsequent ticks always use AUTO, preserving refused or timed-out approval holds. + * + * [serial] covers the entire connect/list/publish/announce operation, including manual and pushed + * refreshes. It also counts the in-flight listing, so stopping a watcher never closes a connection + * under a background answer. Cancellation releases both the listing lock and its ownership count. + */ +class ForegroundRefresh( + private val scope: CoroutineScope, + private val users: ConnectionUsers, + private val close: () -> Unit, + private val refresh: suspend (Trigger) -> Unit, + private val pause: suspend () -> Unit, +) { + private val listing = Mutex() + private var pollJob: Job? = null + + suspend fun serial(block: suspend () -> T): T = users.hold(null) { + listing.withLock { block() } + } + + @Synchronized + fun start(initialTrigger: Trigger = Trigger.USER) { + users.watch() + if (pollJob == null) { + val starting = scope.launch(start = CoroutineStart.LAZY) { + var trigger = initialTrigger + while (isActive) { + refresh(trigger) + trigger = Trigger.AUTO + pause() + } + } + pollJob = starting + starting.start() + } + } + + /** A push or reconnect belongs to the current watcher, including its delay and queued listing. */ + @Synchronized + fun changed(stillCurrent: () -> Boolean = { true }, beforeRefresh: suspend () -> Unit = {}): Job? { + val parent = pollJob ?: return null + if (!users.watched || !parent.isActive) return null + return scope.launch(parent) { + beforeRefresh() + if (isActive && users.watched && stillCurrent()) refresh(Trigger.AUTO) + } + } + + /** Cancel immediately; the last in-flight user delivers a deferred close when it finishes. */ + @Synchronized + fun stop(closeWhenUnused: Boolean = false): Job? { + if (!users.unwatch(if (closeWhenUnused) close else null)) return null + val stopped = pollJob + pollJob = null + stopped?.cancel() + return stopped + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt new file mode 100644 index 0000000000..dab2b179e7 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt @@ -0,0 +1,254 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.KanbanLabel +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.relay.RelaySocket +import dev.nodeterm.protocol.relay.RpcException +import dev.nodeterm.protocol.relay.RpcUnansweredException +import kotlinx.serialization.json.JsonElement +import java.io.Closeable + +/** Receives one attached terminal's output. Called on a transport thread, in order. */ +interface TerminalSink { + /** The current screen to paint BEFORE any live output (the attach snapshot). */ + fun onPaint(text: String) + /** Live terminal bytes (UTF-8; a chunk may end mid code point — decode statefully). */ + fun onOutput(bytes: ByteArray) + /** The host reports the shared pty's real size (a desktop viewer sized it). */ + fun onResized(cols: Int, rows: Int) {} + /** + * This attached stream closed. [code] may describe the viewer or an attach failure, rather than + * the pane's process; even a nonzero exit does not establish that the host session ended. + */ + fun onExit(code: Int?) +} + +/** One phone view of a node's tmux session. Detaching never ends the session. */ +interface TerminalStream { + /** True when the attach had to CREATE the tmux session (cold start: reboot, or a new node). */ + val fresh: Boolean + fun write(text: String) + fun resize(cols: Int, rows: Int) + /** Scroll tmux's own history (its mouse is on): `lines` wheel notches, clamped host-side. */ + suspend fun scroll(up: Boolean, lines: Int) + /** Known direct-SSH tmux and explicit test streams retain their original input scroll path. */ + suspend fun scrollView(up: Boolean, lines: Int): TerminalScrollView.Result { + scroll(up, lines) + return TerminalScrollView.Result.Input + } + /** An explicit input/lifecycle barrier retires this viewer's retained-history token. */ + fun clearScrollView() {} + /** Explicit input-bar Send to this attested viewer/pane; never silently fall back to raw input. */ + suspend fun submitComposed(input: ComposedInput): ComposedInputResult = + ComposedInputResult.refused("Send from history is unavailable on this host. Update nodeterm on the computer; the draft was kept.") + /** Retire unsent composed actions immediately, before a queued raw-I/O detach can run. */ + fun retireComposed() {} + /** Search all history retained by this terminal's host; a legacy host says unsupported. */ + suspend fun searchHistory(query: String): dev.nodeterm.protocol.model.TerminalHistory.Result = + throw HostException("History search is unavailable on this host. Update nodeterm on the computer.") + /** Stop viewing. The session keeps running. */ + suspend fun detach() + /** Permanently end the session and take the node off its canvas (the desktop ×). */ + suspend fun endSession() +} + +enum class TransportKind { RELAY, SSH } + +/** What a connection can do beyond browse + attach; the UI hides what is false and says why. */ +data class HostCapabilities( + val boardWrites: Boolean, + val git: Boolean, + val nodeActions: Boolean, + val registerNode: Boolean, + /** Can answer a held hook-reply approval (docs/hook-reply-approvals.md) without keystrokes. */ + val answerApprovals: Boolean, + /** Host-owned cold canvas creation followed by receipt-only SSH adoption. Older hosts refuse. */ + val managedCreate: Boolean = false +) + +data class NewNode(val id: String, val title: String?, val agentId: String?, val accountId: String?) + +/** `projects.editCardLabels` input (`parseCardLabelEdit` host-side). */ +data class CardLabelEdit( + val add: List = emptyList(), + val remove: List = emptyList(), + val create: List> = emptyList() +) + +data class LabelEditResult(val edited: Boolean, val labels: List, val cardLabelIds: List) + +/** + * What answering a held approval did. [GONE]: the hook's hold had already ended (it deletes its + * request file when it times out after ~45 s, or someone else answered), so nothing was written — + * the interactive prompt may be on screen now. [ALREADY_HANDLED]: a desktop that predates the + * `reason` field said "not answered" without saying why. [FAILED] is never returned: a failed + * write throws [HostException] so the UI can offer a retry. + */ +enum class ApprovalOutcome { SENT, GONE, ALREADY_HANDLED, UNSUPPORTED } + +/** + * One live connection to a paired computer, over either transport. Every call is honest about + * failure: a verb the host does not serve throws [HostException] with the host's own message. + */ +interface HostConnection : Closeable { + val kind: TransportKind + val capabilities: HostCapabilities + + suspend fun listProjects(): ProjectsSnapshot + + /** + * Open a terminal stream on [nodeId]'s session. Cancelling the caller while this is in flight + * leaves nothing attached: a stream the transport opened anyway (the request was already on the + * wire, or the blocking open finished) is detached, never handed to anyone (audit A40). + */ + suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint? = null): TerminalStream + + suspend fun wake(nodeId: String) + suspend fun refresh(nodeId: String) + suspend fun rename(nodeId: String, title: String) + + suspend fun ensureBoard(projectId: String): List? + suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean + suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? + suspend fun registerNode(projectId: String, node: NewNode): Boolean + + suspend fun prepareManagedSession(choice: ManagedSessionChoice): PreparedManagedSession = + throw HostException("Managed session creation is unavailable on this host. Update nodeterm on the computer.") + suspend fun createManagedSession(request: PreparedManagedSession): ManagedSessionReceipt = + throw HostException("Managed session creation is unavailable on this host. Update nodeterm on the computer.") + /** Attach to exactly the confirmed host-created generation. This never creates or launches. */ + suspend fun attachManagedSession(adoption: ManagedSessionAdoption, cols: Int, rows: Int, sink: TerminalSink): TerminalStream = + throw HostException("Connect over SSH to open this host-created terminal.") + + /** + * Answer a held hook-reply approval (one that carries a `pendingId`): the relay's + * `approvals.answer` verb, or the `.answer` file over SSH. [ApprovalOutcome.UNSUPPORTED] means + * this host cannot (an older desktop, no pendingId) — the caller falls back to keystrokes only + * when the prompt is actually on screen. + */ + suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome + /** Remember only a concrete rule supplied by this exact held approval. No keystroke fallback. */ + suspend fun rememberApproval(event: InboxEvent, suggestionIndex: Int): ApprovalOutcome = ApprovalOutcome.UNSUPPORTED + /** Answer all held questions atomically, using option indexes from their full schema. */ + suspend fun answerQuestions(event: InboxEvent, selections: List>): ApprovalOutcome = ApprovalOutcome.UNSUPPORTED + + /** Tell the computer this phone READ a finished session (clears its unread, archives the card). */ + suspend fun ackRead(nodeId: String, eventId: String?) + + /** Type raw keys into a node's pane (quick approve / answer fallback). */ + suspend fun sendKeys(nodeId: String, keys: String) + + /** + * What owns [nodeId]'s pane right now (tmux `#{pane_current_command}`: a shell, or the CLI + * running in it), or null when it cannot be read. Never throws for a failed read: null is + * "unknown", and every caller treats unknown as "not a shell". The relay serves no such verb; + * nothing asks it there, since through the relay the desktop does its own wake ([ResumeOffer]). + */ + suspend fun paneCommand(nodeId: String): String? = null + + suspend fun git(verb: GitVerb, cwd: String, args: Map = emptyMap()): JsonElement? + + /** Fires when the host signals a change worth a re-list (a canvas push, a reconnect). */ + fun setOnChanged(listener: (() -> Unit)?) + /** Fires once when the connection drops on its own. */ + fun setOnClosed(listener: ((String?) -> Unit)?) +} + +/** + * The typed `git.*` verbs of the desktop's jailed git bridge (host-service.ts `handleGit`), and how + * long the phone waits for each. The desktop runs every one with no limit of its own, and each WRITE + * can legitimately take minutes there: push and pull talk to the repository's remote over the + * network; commit runs the repository's pre-commit and commit-msg hooks (a lint or a test run) and + * may wait on a signing passphrase; stage runs clean filters (Git LFS) over what it adds. Giving up + * after the usual RPC wait would report a failure for a command still running there, so the writes + * wait [GIT_WRITE_TIMEOUT_MS]. The reads keep the usual wait. + */ +enum class GitVerb(val wire: String, val timeoutMs: Long = RelaySocket.RPC_TIMEOUT_MS) { + STATUS("git.status"), DIFF("git.diff"), + STAGE("git.stage", GIT_WRITE_TIMEOUT_MS), UNSTAGE("git.unstage", GIT_WRITE_TIMEOUT_MS), + COMMIT("git.commit", GIT_WRITE_TIMEOUT_MS), PUSH("git.push", GIT_WRITE_TIMEOUT_MS), PULL("git.pull", GIT_WRITE_TIMEOUT_MS), + HISTORY("git.history") +} + +/** How long a git write may take on the computer before the phone stops waiting (see [GitVerb]). */ +const val GIT_WRITE_TIMEOUT_MS = 180_000L + +open class HostException(message: String) : Exception(message) + +/** + * The request was sent and no answer came back (it timed out, or the connection dropped while it + * waited), so whatever it asked for may have happened on the computer, or may still be happening. + * A refusal or a failed command is an answer, never this. + */ +class HostUnansweredException(message: String) : HostException(message) + +/** The [HostException] a relay request's failure surfaces as; an unanswered one stays unanswered. */ +internal fun hostException(e: RpcException): HostException { + val message = e.message ?: "Request failed." + return if (e is RpcUnansweredException) HostUnansweredException(message) else HostException(message) +} + +/** + * The direct-SSH transport will not do this for [nodeId], and the relay should: the node's tmux + * session is not running (creating it over SSH would give it no hook environment — audit A08), or + * the node belongs to one of the desktop's SSH projects and lives on another host (audit A09). The + * app offers to open it through the relay, where the desktop attaches it properly. + * + * [message] is that offer's text. [fact] is the same refusal with nothing about the relay, and + * [action] what the user would do with the session in nodeterm on the computer ("open it", "start + * it"). When the phone cannot offer the relay, [refusal] says why for the leg it has: remote access + * not set up (or a computer added by its SSH address, which has no relay at all — audit A27), off + * right now, not picked up yet, or this computer set to "Only on my network". + */ +class NeedsRelayException( + val nodeId: String, + message: String, + val fact: String = message, + val action: String = "open it" +) : HostException(message) { + /** The refusal for a phone with no relay leg at all: remote access isn't set up for this computer. */ + val withoutRelay: String get() = refusal(LegRouting.RelayLeg.NOT_SET_UP)!! + + /** + * What to say when the relay leg is [leg]; null when it is [LegRouting.RelayLeg.AVAILABLE], where + * the app offers the relay ([message]) instead. Each text names what is actually in the way, the + * way [LegRouting.route]'s reasons do: telling a user whose remote access is ON that it isn't set + * up (the review of A27b) sends them to a setting that is already right. + */ + fun refusal(leg: LegRouting.RelayLeg): String? = when (leg) { + LegRouting.RelayLeg.AVAILABLE -> null + LegRouting.RelayLeg.NOT_SET_UP, LegRouting.RelayLeg.ADDED_OVER_SSH -> + "$fact Remote access isn't set up for this computer, so $action in nodeterm on the computer." + LegRouting.RelayLeg.REMOTE_ACCESS_OFF -> + "$fact Remote access is off on the computer right now: turn it on in nodeterm → Settings → Phone, or " + + "$action in nodeterm on the computer." + LegRouting.RelayLeg.NOT_PICKED_UP -> + "$fact Remote access is on there, but this phone has not picked up its relay connection yet: tap Refresh " + + "on the computer's screen and try again, or $action in nodeterm on the computer." + LegRouting.RelayLeg.ROUTE_SSH_ONLY -> + "$fact This computer is set to \"Only on my network (SSH)\": choose \"Automatic\" or \"Only through the " + + "relay\" in Settings → How to reach each computer, or $action in nodeterm on the computer." + } + + companion object { + /** The sentence a refusal ends with when there is no relay leg to open it through. */ + const val NO_RELAY_TO_OFFER = "Remote access isn't set up for this computer, so open it in nodeterm on the computer." + } +} + +/** + * The SSH server answered but did not accept this phone's key (or this user). Kept apart from other + * connect failures because what to do differs: a computer added by its address needs the phone's + * key in its `authorized_keys` first (audit A27), and a paired one was revoked or re-keyed. + */ +class SshAuthRefusedException(message: String) : HostException(message) + +/** + * For an attach that STARTS a session the phone just created: which project it belongs to, and the + * account/agent chosen. A current desktop creates the session in that project's folder under that + * account itself (audit A33 — the phone's `cd`/env launch prefix is POSIX-only and cannot work on a + * Windows host); an older one ignores it. Direct SSH never creates sessions, so it ignores it too. + */ +data class NewSessionHint(val projectId: String, val accountId: String?, val agentId: String?) diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostLifetime.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostLifetime.kt new file mode 100644 index 0000000000..9595502719 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostLifetime.kt @@ -0,0 +1,78 @@ +package dev.nodeterm.protocol.host + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +/** Local publication leases and owned pending dials; no network work runs under this monitor. */ +class HostLifetime { + class Lease internal constructor(internal val owner: HostLifetime, internal val generation: Long) + @Volatile private var generation = 0L + @Volatile private var retired = false + private val pending = mutableSetOf() + val active: Boolean get() = !retired + + @Synchronized + fun capture(): Lease { + if (retired) throw CancellationException("This computer connection was forgotten.") + return Lease(this, generation) + } + + /** Nonlocking so a HostStore publication gate never takes this monitor in reverse order. */ + fun isCurrent(lease: Lease): Boolean = lease.owner === this && !retired && generation == lease.generation + + fun requireCurrent(lease: Lease) { + if (!isCurrent(lease)) throw CancellationException("This computer connection changed.") + } + + @Synchronized + fun publish(lease: Lease, block: () -> T): T { + requireCurrent(lease) + return block() + } + + @Synchronized + fun whenCurrent(lease: Lease, block: () -> Unit): Boolean { + if (!isCurrent(lease)) return false + block() + return true + } + + /** Cancel the exact pending transport operation, never unrelated user work. A blocking SSH + * child remains structured until it returns, letting its caller close the returned socket. */ + suspend fun dial(lease: Lease, block: suspend () -> T): T = coroutineScope { + val operation = async(start = CoroutineStart.LAZY) { block() } + synchronized(this@HostLifetime) { + requireCurrent(lease) + pending += operation + } + operation.invokeOnCompletion { synchronized(this@HostLifetime) { pending.remove(operation) } } + operation.start() + operation.await() + } + + /** Disconnect may reconnect, but work admitted before it cannot publish to the next dial. */ + fun disconnect(close: () -> Unit) = invalidate(permanent = false, close) + + /** A forgotten object never reconnects or publishes again, even if its local id is reused. */ + fun retire(close: () -> Unit) = invalidate(permanent = true, close) + + private fun invalidate(permanent: Boolean, close: () -> Unit) { + var cancel = emptyList() + try { + synchronized(this) { + if (permanent) retired = true + generation++ + cancel = pending.toList() + pending.clear() + close() + } + } finally { + // Cancellation handlers may re-enter native/store callbacks. Never run them under + // our monitor. This stops local approval polling; it does not revoke desktop consent. + for (operation in cancel) operation.cancel(CancellationException("This computer connection changed.")) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostSessionRegistry.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostSessionRegistry.kt new file mode 100644 index 0000000000..02c72f1dc3 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostSessionRegistry.kt @@ -0,0 +1,30 @@ +package dev.nodeterm.protocol.host + +/** + * The factory admission barrier for live host sessions. Retirement and local publication share + * the same monitor as creation, so a replacement cannot acquire an unretired session in between. + * Callbacks must only update local state or dispatch cleanup; never await network work here. + */ +class HostSessionRegistry( + private val create: (String) -> T, + private val retire: (String, T?) -> Unit, +) { + private val sessions = HashMap() + + @Synchronized + fun session(hostId: String): T = sessions.getOrPut(hostId) { create(hostId) } + + @Synchronized + fun forget(hostId: String) { + retire(hostId, sessions.remove(hostId)) + } + + @Synchronized + fun retireAndPublish(hostIds: Collection, publish: () -> R): R { + for (hostId in hostIds.distinct()) forget(hostId) + return publish() + } + + @Synchronized + fun all(): List = sessions.values.toList() +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/InboxNotificationActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/InboxNotificationActions.kt new file mode 100644 index 0000000000..11ff14a3b7 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/InboxNotificationActions.kt @@ -0,0 +1,431 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.model.J.strings +import dev.nodeterm.protocol.model.QuestionChoices +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * The actions an Inbox notification carries, and what it says once one has run (audit A25). + * + * The iOS app gets numbered actions on the APNs push the desktop sends (src/core/push-notify.ts: "The + * backend renders them as numbered notification actions"). Android has no push leg (the backend's + * `/v1/push` routes are APNs-only), but the notifications the app posts itself can carry the same + * answers: Approve / Deny on an approval and an option on a single-select question, sent through + * [QuickActions] with all its re-checks (still unresolved, the node-state rules, the ticketed path + * of audit A38), exactly as the Inbox card sends them. This object is the decision: which actions an + * event gets ([plan]), how an answer runs ([perform]) and what the notification says afterwards + * ([settled]). It is pure, so the JVM tests it; the app's part (the PendingIntents, the receiver, the + * expedited worker) is pinned in the app's source. + * + * The rules: + * - **The card's rule, not a second one.** An approval gets Approve / Deny only when + * [QuickActions.answersApproval] says it can be answered from outside the session, and a question + * gets its options only when [QuestionChoices] lists them as answers. What the answer path would + * turn into "open the session" is not offered; the notification offers Open instead. + * - **Three at most** ([MAX_ACTIONS]): Android shows no more. A question with more options than that + * offers Open, never its first three: a tap would answer a picker whose other options were never + * shown. + * - **Never a first relay handshake** ([reachableQuietly]). The user tapped, but is not looking at + * the app, so nobody would compare the approval code the desktop shows on a phone's first relay + * connect (RelayApprovalGate, audit A05). An answer goes over the SSH leg or over a relay that has + * already approved this phone; a computer reachable only otherwise gets Open, which opens the app. + * - **No event text in a label unless the user asked for it** (audit A52): a question's options are + * "Option 1", "Option 2", … unless "Show details in notifications" is on. Approve and Deny say + * nothing about the request. + * - **Never retried.** An answer that may or may not have arrived is said to be unconfirmed + * ([Outcome.UNCONFIRMED]), never sent again: a second `1` lands in whatever the pane shows by then. + * That includes the run WorkManager starts again on its own after an interrupted one ([answerOnce]). + * - **Keys only for a card the computer still lists** ([QuickActions]): a notification can stay in the + * shade for hours after its card was settled and dropped from the desktop's feed, while the node + * blocks on a newer prompt; its Approve must not answer that one. + */ +object InboxNotificationActions { + /** Android shows at most three actions on a notification. */ + const val MAX_ACTIONS = 3 + + /** An option's label is cut to this many characters (only with details on: the label is its text). */ + const val MAX_LABEL_CHARS = 40 + + /** How long reaching the computer may take before the notification says it could not be reached. */ + const val CONNECT_BUDGET_MS = 30_000L + + /** How long the answer may take once connected before the notification says it is unconfirmed. */ + const val ANSWER_BUDGET_MS = 30_000L + + /** How long a settled notification ("Approved.") stays before it goes away by itself. */ + const val SETTLED_SHOWN_MS = 10_000L + + /** [Action.option] of an action that is not an option. */ + const val NO_OPTION = -1 + + /** The label of the action that opens the session instead of answering. */ + const val OPEN_LABEL = "Open" + + /** The notification id of [eventId] on [hostId]: one notification per event per computer. */ + fun notificationId(hostId: String, eventId: String): Int = "$hostId:$eventId".hashCode() + + enum class Verb(val wire: String) { + APPROVE("approve"), + DENY("deny"), + /** Picks the question's option [Action.option] (0-based; the digit typed is one more). */ + OPTION("option"), + /** Opens the session; answers nothing. */ + OPEN("open"); + + companion object { + fun of(wire: String?): Verb? = entries.firstOrNull { it.wire == wire } + } + } + + data class Action(val verb: Verb, val label: String, val option: Int = NO_OPTION) { + /** + * Answers from the notification (a broadcast to the app, which needs an unlocked phone); + * [Verb.OPEN] opens the session instead. + */ + val answers: Boolean get() = verb != Verb.OPEN + + /** Unique among one notification's actions: what tells their PendingIntents apart. */ + val key: String get() = if (verb == Verb.OPTION) "${verb.wire}-$option" else verb.wire + } + + /** + * Whether the computer can be reached without a FIRST relay handshake: over its SSH leg ([sshLeg]: + * the pairing installed a key and the route allows SSH), or through a relay leg that has already + * approved this phone ([relayLeg] and [relayApproved]). A notification action uses only these; an + * open connection may be reused whatever it is, since reusing it makes no handshake. + * + * The SSH leg counts whether or not the phone is on the computer's network right now, which it + * cannot know before it dials. So a computer paired with an SSH key always gets the answers, and + * one tapped away from its network goes through the relay if that has approved this phone, or + * else ends as [Outcome.UNREACHABLE] with nothing sent. Only a computer reached through the relay + * alone, which has not approved this phone, gets Open; but the listing that raised the + * notification normally came over that relay and so approved it, and in practice the answers are + * offered and it is the tap that ends as [Outcome.NOT_APPROVED], if the approval was lost since. + */ + fun reachableQuietly(sshLeg: Boolean, relayLeg: Boolean, relayApproved: Boolean): Boolean = + sshLeg || (relayLeg && relayApproved) + + /** + * The actions a notification for [event] carries, at most [MAX_ACTIONS]. None for a finished turn + * or a settled card (the tap opens the session). [showDetails] is "Show details in notifications"; + * [reachableQuietly] is [InboxNotificationActions.reachableQuietly] for the event's computer. + */ + fun plan(event: InboxEvent, showDetails: Boolean, reachableQuietly: Boolean): List { + if (!event.actionable) return emptyList() + val open = listOf(Action(Verb.OPEN, OPEN_LABEL)) + if (!reachableQuietly) return open + return when (event.kind) { + InboxKind.APPROVAL -> + if (QuickActions.answersApproval(event)) listOf(Action(Verb.APPROVE, "Approve"), Action(Verb.DENY, "Deny")) + else open + InboxKind.QUESTION -> { + val choices = QuestionChoices.of(event) as? QuestionChoices.Answer + if (choices == null || choices.rows.size > MAX_ACTIONS) open + else choices.rows.mapIndexed { i, row -> + Action(Verb.OPTION, if (showDetails) cap(row) else "Option ${i + 1}", option = i) + } + } + InboxKind.DONE -> emptyList() + } + } + + private fun cap(label: String): String { + val oneLine = label.replace(Regex("\\s+"), " ").trim() + return if (oneLine.length <= MAX_LABEL_CHARS) oneLine else oneLine.take(MAX_LABEL_CHARS - 1).trimEnd() + "…" + } + + /** What happened to an answer given from a notification. */ + enum class Outcome { + APPROVED, + DENIED, + ANSWERED, + /** The card was settled before the answer arrived (on the computer, or elsewhere). Nothing was sent. */ + ALREADY_HANDLED, + /** The hook's hold ended before the answer arrived; the prompt is in the session now (audit A06). */ + EXPIRED, + /** The answer path sends this one to the session after all (QuickActions said so). */ + OPEN_SESSION, + /** Reaching the computer would take a first relay handshake, whose code nobody would see. Nothing was sent. */ + NOT_APPROVED, + /** The computer could not be reached. Nothing was sent. */ + UNREACHABLE, + /** Connected, but the answer was not confirmed: it may or may not have reached the computer. */ + UNCONFIRMED, + /** The phone was locked (Android 11 and lower, whose system does not ask for the unlock). Nothing was sent. */ + LOCKED, + /** The computer is no longer paired with this phone. Nothing was sent. */ + NOT_PAIRED + } + + /** [QuickActions]' [result] of [action], as the notification reports it. */ + fun outcomeOf(action: Action, result: QuickActions.Result): Outcome = when (result) { + QuickActions.Result.SENT -> when (action.verb) { + Verb.APPROVE -> Outcome.APPROVED + Verb.DENY -> Outcome.DENIED + Verb.OPTION -> Outcome.ANSWERED + Verb.OPEN -> Outcome.OPEN_SESSION + } + QuickActions.Result.ALREADY_HANDLED -> Outcome.ALREADY_HANDLED + QuickActions.Result.EXPIRED -> Outcome.EXPIRED + QuickActions.Result.OPEN_SESSION -> Outcome.OPEN_SESSION + } + + /** + * What the notification says once an answer settled: [text], and how long it stays + * ([dismissAfterMs]; null = until the user deals with it). Its tap always opens the session. An + * outcome that leaves something to do says "Tap" and stays; one that is done goes away by itself. + * No outcome carries the event's own text (audit A52). + */ + data class Settled(val text: String, val dismissAfterMs: Long?) + + fun settled(outcome: Outcome, action: Action, computerName: String): Settled = when (outcome) { + Outcome.APPROVED -> Settled("Approved.", SETTLED_SHOWN_MS) + Outcome.DENIED -> Settled("Denied.", SETTLED_SHOWN_MS) + Outcome.ANSWERED -> Settled("Answered with option ${action.option + 1}.", SETTLED_SHOWN_MS) + Outcome.ALREADY_HANDLED -> Settled("Already handled.", SETTLED_SHOWN_MS) + Outcome.EXPIRED -> Settled("The request timed out on the computer. Tap to answer it in the session.", null) + Outcome.OPEN_SESSION -> Settled("This one is answered in the session. Tap to open it.", null) + Outcome.NOT_APPROVED -> Settled( + "Not sent: this phone reaches $computerName only through the relay, and it has not approved this phone " + + "yet. Tap to open the app and connect.", + null + ) + Outcome.UNREACHABLE -> Settled("Not sent: couldn't reach $computerName. Tap to answer it in the session.", null) + Outcome.UNCONFIRMED -> Settled("Couldn't confirm that the answer reached $computerName. Tap to check the session.", null) + Outcome.LOCKED -> Settled("Not sent: unlock the phone first. Tap to answer it in the session.", null) + Outcome.NOT_PAIRED -> Settled("Not sent: $computerName is no longer paired with this phone.", null) + } + + /** What the notification says while the answer is on its way (its actions are gone by then). */ + fun sendingText(action: Action): String = when (action.verb) { + Verb.APPROVE -> "Approving…" + Verb.DENY -> "Denying…" + Verb.OPTION -> "Answering with option ${action.option + 1}…" + Verb.OPEN -> "Opening…" + } + + /** + * Runs [action] on [event] through [QuickActions], over [connect] (the computer's connection) and, + * when the direct-SSH transport sends the node to the relay ([NeedsRelayException], audit A09), + * over [viaRelay]. Both must never make a first relay handshake: the caller passes the + * background trigger ([RelayApprovalGate.Trigger.BACKGROUND]). Never throws but for cancellation. + * + * A dial that fails or takes longer than [connectBudgetMs] has sent nothing ([Outcome.UNREACHABLE]); + * an answer that fails or takes longer than [answerBudgetMs] once connected may have + * ([Outcome.UNCONFIRMED]), and is not retried. + */ + suspend fun perform( + action: Action, + event: InboxEvent, + connect: suspend () -> HostConnection, + viaRelay: suspend () -> HostConnection, + connectBudgetMs: Long = CONNECT_BUDGET_MS, + answerBudgetMs: Long = ANSWER_BUDGET_MS + ): Outcome { + if (!action.answers) return Outcome.OPEN_SESSION + val primary = dial(connect, connectBudgetMs) ?: return Outcome.UNREACHABLE + var attempt = attempt(primary, action, event, answerBudgetMs) + if (attempt is Attempt.NeedsRelay) { + // The SSH transport refused before sending anything; the node is answered where it lives. + val relay = dial(viaRelay, connectBudgetMs) ?: return Outcome.UNREACHABLE + attempt = attempt(relay, action, event, answerBudgetMs) + } + return when (attempt) { + is Attempt.Answered -> outcomeOf(action, attempt.result) + // The relay leg never refuses a node that way; if it did, it sent nothing. + Attempt.NeedsRelay -> Outcome.UNREACHABLE + Attempt.Unconfirmed -> Outcome.UNCONFIRMED + } + } + + /** + * One computer as an answer from a notification reaches it ([answerOnce]): the app's HostSession, + * whose every dial is the background one ([RelayApprovalGate.Trigger.BACKGROUND]), which never + * makes a first relay handshake. + */ + interface QuietRoute { + /** A connection to the computer is open already: reusing it makes no handshake. */ + val connected: Boolean + + /** [InboxNotificationActions.reachableQuietly] for the computer, now. */ + fun reachableQuietly(): Boolean + + /** + * Runs [block] holding the computer's connection, which other users share (a second answer, the + * background check, a screen): it is closed afterwards only when none of them still uses it + * ([ConnectionUsers]). + */ + suspend fun hold(block: suspend () -> Outcome): Outcome + + /** The computer's connection ([perform]'s `connect`). */ + suspend fun connect(): HostConnection + + /** The relay leg next to it ([perform]'s `viaRelay`). */ + suspend fun viaRelay(): HostConnection + } + + /** + * The whole answer a notification's work gives, in this order: + * + * 1. A run that is not the work's first ([runAttempt] > 0, WorkManager's run attempt count) sends + * nothing and says the answer is unconfirmed. WorkManager runs work again when it was + * interrupted rather than finished: the system stopped the job, or the process died while it + * ran (the work is set back to enqueued at the next start, possibly hours later). The first run + * may have sent the answer, and nothing records whether it did; a second `1` would land in + * whatever the pane shows by then. + * 2. [route] null: the computer is no longer paired ([Outcome.NOT_PAIRED]). + * 3. Holding the connection ([QuietRoute.hold]) for the rest: with no connection open and no quiet + * reach, [Outcome.NOT_APPROVED] and nothing is dialed; else [perform]. + */ + suspend fun answerOnce( + action: Action, + event: InboxEvent, + runAttempt: Int, + route: QuietRoute?, + connectBudgetMs: Long = CONNECT_BUDGET_MS, + answerBudgetMs: Long = ANSWER_BUDGET_MS + ): Outcome { + if (runAttempt > 0) return Outcome.UNCONFIRMED + if (!action.answers) return Outcome.OPEN_SESSION + val r = route ?: return Outcome.NOT_PAIRED + return r.hold { + if (!r.connected && !r.reachableQuietly()) Outcome.NOT_APPROVED + else perform(action, event, { r.connect() }, { r.viaRelay() }, connectBudgetMs, answerBudgetMs) + } + } + + private sealed interface Attempt { + data class Answered(val result: QuickActions.Result) : Attempt + data object NeedsRelay : Attempt + data object Unconfirmed : Attempt + } + + private suspend fun dial(open: suspend () -> HostConnection, budgetMs: Long): HostConnection? = try { + withTimeoutOrNull(budgetMs) { open() } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + + private suspend fun attempt(conn: HostConnection, action: Action, event: InboxEvent, budgetMs: Long): Attempt = try { + withTimeoutOrNull(budgetMs) { Attempt.Answered(answer(conn, action, event)) } ?: Attempt.Unconfirmed + } catch (_: NeedsRelayException) { + Attempt.NeedsRelay + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + Attempt.Unconfirmed + } + + private suspend fun answer(conn: HostConnection, action: Action, event: InboxEvent): QuickActions.Result = when (action.verb) { + Verb.APPROVE -> QuickActions.answerApproval(conn, event, allow = true) + Verb.DENY -> QuickActions.answerApproval(conn, event, allow = false) + Verb.OPTION -> QuickActions.answerQuestion(conn, event, action.option) + Verb.OPEN -> QuickActions.Result.OPEN_SESSION + } + + /** + * One tap on an answering action, as the app hands it from the notification to its receiver and on + * to the worker (an Intent extra, then the work's input; both are strings, so it is one JSON + * string, [encode]). It names the notification to update ([notificationId], with the words it + * shows: [headline], [computerName]), the terminal its tap opens ([sessionTitle]), the [action], + * and the [event] with the fields [QuickActions] reads. Never the event's title or detail, which + * nothing after the tap needs (audit A52). + */ + data class Request( + val hostId: String, + val computerName: String, + val notificationId: Int, + /** The notification's title ([dev.nodeterm.protocol.model.InboxNotificationText.title]). */ + val headline: String, + /** The session's name, for the terminal the notification's tap opens. */ + val sessionTitle: String, + val action: Action, + val event: InboxEvent + ) { + /** One answer per event in flight: a second tap while the first runs is ignored. */ + val workName: String get() = "nodeterm.inbox.answer.$hostId.${event.id}" + + fun encode(): String = JsonObject( + mapOf( + "v" to JsonPrimitive(VERSION), + "hostId" to JsonPrimitive(hostId), + "computer" to JsonPrimitive(computerName.take(MAX_TEXT)), + "notificationId" to JsonPrimitive(notificationId), + "headline" to JsonPrimitive(headline.take(MAX_TEXT)), + "sessionTitle" to JsonPrimitive(sessionTitle.take(MAX_TEXT)), + "verb" to JsonPrimitive(action.verb.wire), + "label" to JsonPrimitive(action.label), + "option" to JsonPrimitive(action.option), + "event" to JsonObject( + buildMap { + put("id", JsonPrimitive(event.id)) + put("ts", JsonPrimitive(event.ts)) + put("nodeId", JsonPrimitive(event.nodeId)) + event.agentId?.let { put("agentId", JsonPrimitive(it)) } + event.sessionId?.let { put("sessionId", JsonPrimitive(it)) } + put("kind", JsonPrimitive(event.kind.wire)) + put("options", JsonArray(event.options.take(MAX_OPTIONS).map { JsonPrimitive(it.take(MAX_TEXT)) })) + put("multiSelect", JsonPrimitive(event.multiSelect)) + event.pendingId?.let { put("pendingId", JsonPrimitive(it)) } + } + ) + ) + ).toString() + + companion object { + private const val VERSION = 1L + private const val MAX_TEXT = 200 + private const val MAX_OPTIONS = 9 + + /** The request [encode] wrote, or null for anything else (a missing or malformed field). */ + fun decode(raw: String?): Request? { + val o = J.obj(J.parse(raw ?: return null)) ?: return null + if (o.l("v") != VERSION) return null + val hostId = o.s("hostId")?.takeIf { it.isNotEmpty() } ?: return null + val notificationId = o.l("notificationId")?.takeIf { it in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() }?.toInt() ?: return null + val verb = Verb.of(o.s("verb")) ?: return null + val option = o.l("option")?.toInt() ?: return null + if (verb == Verb.OPTION && option !in 0..8) return null + val e = o.o("event") ?: return null + val event = InboxEvent( + id = e.s("id")?.takeIf { it.isNotEmpty() } ?: return null, + ts = e.l("ts") ?: return null, + nodeId = e.s("nodeId")?.takeIf { it.isNotEmpty() } ?: return null, + agentId = e.s("agentId"), + sessionId = e.s("sessionId"), + kind = InboxKind.of(e.s("kind")) ?: return null, + title = "", + detail = null, + interrupted = false, + resolved = false, + options = e.strings("options"), + multiSelect = e.b("multiSelect") ?: return null, + pendingId = e.s("pendingId") + ) + return Request( + hostId = hostId, + computerName = o.s("computer") ?: return null, + notificationId = notificationId, + headline = o.s("headline") ?: return null, + sessionTitle = o.s("sessionTitle") ?: return null, + action = Action(verb, o.s("label") ?: return null, option), + event = event + ) + } + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/LegRouting.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/LegRouting.kt new file mode 100644 index 0000000000..4ebefedab3 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/LegRouting.kt @@ -0,0 +1,207 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.ProjectInfo + +/** + * One thing a connection can do beyond browse + attach, as [HostCapabilities] names it. The verbs + * under each are the host-service ones the relay serves. + */ +enum class Capability(val what: String) { + /** `projects.ensureBoard`, `projects.setCardColumn`, `projects.editCardLabels`. */ + BOARD_WRITES("Editing the board"), + /** `git.*`. */ + GIT("Source control"), + /** `node.wake`, `node.refresh`, `node.rename`. */ + NODE_ACTIONS("Waking, refreshing and renaming a session"), + /** `projects.registerNode` (a session the phone starts, put on the canvas). */ + REGISTER_NODE("Starting a new session"), + /** `approvals.answer` / the `.answer` file. */ + ANSWER_APPROVALS("Answering an approval"); + + fun of(c: HostCapabilities): Boolean = when (this) { + BOARD_WRITES -> c.boardWrites + GIT -> c.git + NODE_ACTIONS -> c.nodeActions + REGISTER_NODE -> c.registerNode + ANSWER_APPROVALS -> c.answerApprovals + } +} + +/** + * Which leg of a paired computer answers a verb (audit A26), decided in ONE place so the UI and the + * call agree. The direct-SSH leg (the LAN one `Auto` picks first) is POSIX sh + tmux on the machine; + * what needs nodeterm THE APP — board writes, registering a new session and node actions — is the + * relay's. Before this, a phone on SSH simply had none of it (the New-session button vanished, the + * board was read-only), although the same computer's relay leg was one tap away. + * + * The rule: the primary connection when it can; else the relay leg opened NEXT to it on demand, when + * this phone holds one; else unavailable, with the reason the UI shows on a disabled control. + * + * The relay is opened only for a user's action (a tap): [dev.nodeterm.protocol.host.RelayApprovalGate] + * still decides, so a background path never makes a first relay handshake. + */ +object LegRouting { + /** What the relay leg serves: everything host-service has a verb for. */ + val RELAY_CAPABILITIES = HostCapabilities( + boardWrites = true, git = true, nodeActions = true, registerNode = true, answerApprovals = true + ) + + /** Whether this phone can reach the computer's relay leg next to its primary connection. */ + enum class RelayLeg { + /** + * A relay block, its host key and a stored device token, the route allows the relay, and the + * computer was not seen with remote access off. + */ + AVAILABLE, + /** The phone holds no relay leg: remote access was off at pairing and has not been adopted since. */ + NOT_SET_UP, + /** + * The phone holds no relay leg yet, but the computer advertises one (remote access is on + * there): late adoption has not run, or failed, on this connection. A refresh adopts it. + */ + NOT_PICKED_UP, + /** + * The phone holds a relay leg, but the computer is not advertising its relay right now: remote + * access was turned off on it after the phone got its token. The token is kept (turning remote + * access back on makes it work again); a dial would only wait out the relay's timeout. + */ + REMOTE_ACCESS_OFF, + /** The phone has one, but the user set this computer to "Only on my network". */ + ROUTE_SSH_ONLY, + /** + * The computer was added by its SSH address, not paired (audit A27): it has no relay leg and + * never gets one, so turning remote access on would not help. + */ + ADDED_OVER_SSH + } + + sealed interface Leg { + /** The connection already open does it. */ + data object Primary : Leg + /** Open (or reuse) the relay connection held next to the primary one. */ + data object Relay : Leg + /** Neither leg can; [reason] is what a disabled control says. */ + data class Unavailable(val reason: String) : Leg + } + + /** + * Facts the relay leg's availability comes from. Pure, so the app and its tests agree. + * [addedOverSsh] (a computer added by its SSH address, audit A27) wins over the rest: such a + * computer has no relay leg, whatever else the phone holds. + * + * [relayConfigured] is what the phone STORES (a relay block, its host key, a device token — asked + * by presence, never by decrypting it, so a screen may ask while composing; audit A47). + * [relayAdvertised] is what the computer said at the last listing over direct SSH (whether its + * `~/.nodeterm/relay.json` is there), null when unknown (not on SSH, or not listed yet). Unknown + * never takes a leg away: it is what the relay connection itself, or a first connect, looks like. + */ + fun relayLeg( + relayConfigured: Boolean, + sshOnlyRoute: Boolean, + addedOverSsh: Boolean = false, + relayAdvertised: Boolean? = null + ): RelayLeg = when { + addedOverSsh -> RelayLeg.ADDED_OVER_SSH + !relayConfigured && relayAdvertised == true -> RelayLeg.NOT_PICKED_UP + !relayConfigured -> RelayLeg.NOT_SET_UP + relayAdvertised == false -> RelayLeg.REMOTE_ACCESS_OFF + sshOnlyRoute -> RelayLeg.ROUTE_SSH_ONLY + else -> RelayLeg.AVAILABLE + } + + /** + * Whether a listing over direct SSH should run late adoption now: the phone holds no relay leg + * and the computer advertises one, and either the user asked (Refresh, Try again, opening the + * computer) or the advertisement has just appeared on this connection (remote access was turned + * on while the phone watched). Not on every poll: each attempt mints a token against the API. + */ + fun adoptAfterListing(relay: RelayLeg, advertisedBefore: Boolean?, advertisedNow: Boolean?, userAsked: Boolean): Boolean = + (relay == RelayLeg.NOT_SET_UP || relay == RelayLeg.NOT_PICKED_UP) && advertisedNow == true && + (userAsked || advertisedBefore == false) + + /** + * Where [cap] goes. [primary] is the open connection's transport (null: not connected yet), with + * its [primaryCaps]. + */ + fun route(cap: Capability, primary: TransportKind?, primaryCaps: HostCapabilities?, relay: RelayLeg): Leg { + if (primary != null && primaryCaps != null && cap.of(primaryCaps)) return Leg.Primary + if (primary == TransportKind.RELAY) { + // The relay itself refused: nothing else to open. + return Leg.Unavailable("${cap.what} isn't available on this computer.") + } + return when (relay) { + RelayLeg.AVAILABLE -> Leg.Relay + RelayLeg.NOT_SET_UP -> Leg.Unavailable(notSetUp(cap)) + RelayLeg.NOT_PICKED_UP -> Leg.Unavailable(notPickedUp(cap)) + RelayLeg.REMOTE_ACCESS_OFF -> Leg.Unavailable(remoteAccessOff(cap)) + RelayLeg.ROUTE_SSH_ONLY -> Leg.Unavailable(sshOnly(cap)) + RelayLeg.ADDED_OVER_SSH -> Leg.Unavailable(addedOverSsh(cap)) + } + } + + /** + * Where [cap] goes for something in [project], given where it goes for the computer ([leg], + * from [route]). A project a desktop ELSEWHERE drives over SSH ([ProjectInfo.drivenRemotely], + * audit A27) is that desktop's: its sessions run on this computer, but its canvas, board and node + * actions are nodeterm the app's on the OTHER computer, which neither leg of this one reaches — + * this computer's relay would answer for its own canvas. So everything but what the machine does + * itself (answering a held approval, or direct-SSH Git in a listed local folder) is unavailable, with that reason. + */ + fun forProject(cap: Capability, project: ProjectInfo?, leg: Leg): Leg = + if (project?.drivenRemotely == true && cap != Capability.ANSWER_APPROVALS && + !(cap == Capability.GIT && leg == Leg.Primary)) Leg.Unavailable(drivenElsewhere(cap)) else leg + + fun drivenElsewhere(cap: Capability) = + "${cap.what} isn't available for this project from the phone: it belongs to nodeterm on another computer, " + + "which runs its sessions here over SSH. Use nodeterm on that computer." + + /** What each capability can reach right now, for a screen deciding several controls at once. */ + fun reach(primary: TransportKind?, primaryCaps: HostCapabilities?, relay: RelayLeg): Map = + Capability.entries.associateWith { route(it, primary, primaryCaps, relay) } + + /** + * The pickup it promises is real: every listing over direct SSH (the host screen's 8 s refresh, + * and Refresh) reads whether the computer advertises its relay, and adopts it when it newly does + * ([adoptAfterListing]). + */ + private fun notSetUp(cap: Capability) = + "${cap.what} goes through nodeterm on the computer, which this phone reaches through the relay, and " + + "this phone has no relay connection to it yet. Turn on remote access in nodeterm → Settings → Phone; " + + "the phone picks it up on its next refresh on your network." + + /** Remote access IS on: telling the user to turn it on would be the A26 error text again. */ + private fun notPickedUp(cap: Capability) = + "${cap.what} goes through nodeterm on the computer, which this phone reaches through the relay. Remote " + + "access is on there, but this phone has not picked up its relay connection yet: tap Refresh on the " + + "computer's screen to try again." + + /** + * The phone holds a token, but the computer stopped advertising its relay. Without this the + * controls showed enabled and a tap waited out the relay's handshake timeout before failing. + */ + private fun remoteAccessOff(cap: Capability) = + "${cap.what} goes through nodeterm on the computer, which this phone reaches through the relay, and " + + "remote access is off on the computer right now. Turn it on in nodeterm → Settings → Phone; the phone " + + "notices on its next refresh." + + /** + * A computer added by its SSH address: unlike [notSetUp], nothing on the computer can switch the + * relay on for it (a Server Edition has no pairing service), so the text names no setting. + */ + fun addedOverSsh(cap: Capability) = + "${cap.what} goes through nodeterm on the computer over the relay, and remote access isn't set up for " + + "this computer: it was added by its SSH address, so the phone reaches it over SSH only." + + private fun sshOnly(cap: Capability) = + "${cap.what} goes through the relay, and this computer is set to \"Only on my network (SSH)\". Choose " + + "\"Automatic\" or \"Only through the relay\" in Settings → How to reach each computer." + + /** + * What the direct-SSH transport says when a relay verb is called on it anyway. The app routes + * those to the relay ([route]), so this is the message of a caller that skipped the routing — and + * it must not tell a user whose remote access is ON to turn it on (the phone merely picked SSH). + */ + fun sshRefusal(what: String) = + "$what isn't done over your network: it goes through nodeterm on the computer, which the phone " + + "reaches through the relay." +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ListingFailure.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ListingFailure.kt new file mode 100644 index 0000000000..f6a933e2be --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ListingFailure.kt @@ -0,0 +1,17 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.ssh.NothingFoundException +import kotlinx.coroutines.CancellationException + +/** Which cached listing survives a failed refresh, independently of its error message or route. */ +object ListingFailure { + fun snapshot(previous: ProjectsSnapshot, error: Exception, now: Long = System.currentTimeMillis()): ProjectsSnapshot { + if (error is CancellationException) throw error + // SSH found no workspace, driven nodes or phone shells. This is an empty answer, even + // though A02 still reports where it looked. Other failures cannot prove cached nodes ended. + // The initial EMPTY has fetchedAt=0: the UI is still waiting for its first answer. This + // authoritative empty answer completed a listing, so it must leave that loading state. + return if (error is NothingFoundException) ProjectsSnapshot.EMPTY.copy(fetchedAt = now) else previous + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ManagedSessionCreation.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ManagedSessionCreation.kt new file mode 100644 index 0000000000..581312e801 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ManagedSessionCreation.kt @@ -0,0 +1,161 @@ +package dev.nodeterm.protocol.host + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.isActive +import kotlinx.serialization.json.* + +/** Durable admission precedes dispatch. An uncertain creation is never replayed or replaced. */ +class ManagedSessionCreation( + private val scope: CoroutineScope, + private val storage: Storage, + private val prepare: suspend (ManagedSessionChoice) -> PreparedManagedSession, + private val create: suspend (PreparedManagedSession) -> ManagedSessionReceipt, +) { + interface Storage { + fun read(): String? + /** Must commit synchronously and throw on failure, including removal. */ + fun write(encoded: String) + } + sealed interface State { + data object Idle : State + data class Creating(val choice: ManagedSessionChoice) : State + data class Refused(val message: String) : State + data class Uncertain(val message: String) : State + data class Ready(val adoption: ManagedSessionAdoption) : State + } + private val lock = Any() + private var pending: PreparedManagedSession? = null + private var receipts = emptyList() + private val mutableState = MutableStateFlow(State.Idle) + val state: StateFlow = mutableState.asStateFlow() + private var nextTicket = 0L + private var presentation: Long? = null + + init { + try { + storage.read()?.let { encoded -> + require(encoded.toByteArray(Charsets.UTF_8).size <= 256 * 1024) + val o = Json.parseToJsonElement(encoded).jsonObject + require(o.getValue("version").jsonPrimitive.let { !it.isString && it.int == 1 }) + receipts = o.getValue("receipts").jsonArray.map { ManagedSessions.adoption(it.jsonObject) } + require(receipts.size <= 8 && receipts.map { it.receipt.nodeId }.distinct().size == receipts.size) + o["pending"]?.takeIf { it != JsonNull }?.jsonObject?.let { p -> + pending = ManagedSessions.prepared(p.getValue("request").jsonObject) + mutableState.value = when (p.getValue("phase").jsonPrimitive.let { require(it.isString); it.content }) { + "sent" -> State.Uncertain(UNCERTAIN) + "ready" -> State.Ready(ManagedSessionAdoption(pending!!, ManagedSessions.receipt(p.getValue("receipt"), pending!!)).also { adoption -> require(adoption in receipts) }) + else -> error("Invalid saved creation") + } + } + } + } catch (_: Exception) { + // A corrupt checkpoint is not permission to send a second creation after process death. + mutableState.value = State.Uncertain("The saved creation cannot be read. Check sessions on the computer before starting another session.") + } + } + fun show(): Long = synchronized(lock) { (++nextTicket).also { presentation = it } } + fun hide(ticket: Long) = synchronized(lock) { if (presentation == ticket) presentation = null } + fun isCurrent(ticket: Long): Boolean = synchronized(lock) { presentation == ticket } + + fun submit(ticket: Long, choice: ManagedSessionChoice, onCreated: suspend () -> Unit): Boolean { + synchronized(lock) { + if (!scope.isActive || presentation != ticket || mutableState.value !is State.Idle && mutableState.value !is State.Refused) return false + if (receipts.size >= 8) { + mutableState.value = State.Refused("Open or discard the previous host-created terminals before starting another session.") + return false + } + mutableState.value = State.Creating(choice) + } + scope.launch { + var dispatched = false + try { + val request = prepare(choice) + require(request.choice == choice) + synchronized(lock) { + // Write before create() can publish any remote input. A killed process restores + // an uncertain state, even if no acknowledgement was received on this phone. + persist(request, "sent") + pending = request + dispatched = true + } + val adoption = ManagedSessionAdoption(request, create(request)) + synchronized(lock) { + require(receipts.none { it.receipt.nodeId == adoption.receipt.nodeId && it != adoption }) { "The host reused an unverified terminal identity. Check the computer before discarding its receipt." } + val next = listOf(adoption) + receipts.filterNot { it.receipt.nodeId == adoption.receipt.nodeId } + require(next.size <= 8) + persist(request, "ready", adoption.receipt, next) + receipts = next + mutableState.value = State.Ready(adoption) + } + } catch (cancelled: CancellationException) { + synchronized(lock) { mutableState.value = if (dispatched) State.Uncertain(UNCERTAIN) else State.Refused("Creation was cancelled before it was sent.") } + throw cancelled + } catch (error: Exception) { + synchronized(lock) { + if (!dispatched || error is ManagedSessionRefusedException) { + try { + persist(null, null) + pending = null + mutableState.value = State.Refused(error.message ?: "The computer refused creation.") + } catch (_: Exception) { mutableState.value = State.Uncertain(UNCERTAIN) } + } else mutableState.value = State.Uncertain(error.message ?: UNCERTAIN) + } + return@launch + } + // Presentation failure cannot rewrite an already-confirmed creation outcome. + if (isCurrent(ticket)) onCreated() + } + return true + } + + /** The receipt remains durable until the first guarded attach has completed. */ + fun takeReady(ticket: Long): ManagedSessionAdoption? = synchronized(lock) { + if (presentation != ticket) return null + val ready = mutableState.value as? State.Ready ?: return null + persist(null, null) + pending = null + mutableState.value = State.Idle + ready.adoption + } + fun receiptFor(nodeId: String): ManagedSessionAdoption? = synchronized(lock) { + receipts.firstOrNull { it.receipt.nodeId == nodeId } + } + fun adopted(nodeId: String) = synchronized(lock) { + val next = receipts.filterNot { it.receipt.nodeId == nodeId } + val ready = mutableState.value as? State.Ready + val consumed = ready?.adoption?.receipt?.nodeId == nodeId + val keepPending = pending.takeUnless { consumed } + persist(keepPending, if (ready != null && keepPending != null) "ready" else if (keepPending != null) "sent" else null, + ready?.adoption?.receipt.takeUnless { consumed }, next) + if (consumed) { pending = null; mutableState.value = State.Idle } + receipts = next + } + + /** User starts a genuinely new session after checking the uncertain result; never a retry. */ + fun acknowledgeChecked(ticket: Long): Boolean = synchronized(lock) { + if (presentation != ticket || mutableState.value !is State.Uncertain) return false + persist(null, null) + pending = null + mutableState.value = State.Idle + true + } + private fun persist(request: PreparedManagedSession?, phase: String?, receipt: ManagedSessionReceipt? = null, retained: List = receipts) { + val encoded = buildJsonObject { + put("version", 1) + put("receipts", JsonArray(retained.map { it.toJson() })) + put("pending", request?.let { r -> buildJsonObject { + put("request", r.toJson()); put("phase", phase!!); receipt?.let { put("receipt", it.toJson()) } + } } ?: JsonNull) + }.toString() + require(encoded.toByteArray(Charsets.UTF_8).size <= 256 * 1024) + storage.write(encoded) + } + companion object { + const val UNCERTAIN = "No confirmed creation result came back. Check sessions on the computer; this request will not be sent again." + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ManagedSessions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ManagedSessions.kt new file mode 100644 index 0000000000..1f4312e9d4 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ManagedSessions.kt @@ -0,0 +1,122 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.ssh.SshActionsScripts +import kotlinx.serialization.json.* + +/** Only choices cross this boundary. The owning host resolves commands, folders and credentials. */ +data class ManagedSessionChoice( + val projectId: String, + val kind: String, + val agentId: String? = null, + val accountId: String? = null, + val title: String? = null, + val cols: Int = 80, + val rows: Int = 24, +) { + init { + require(ManagedSessions.validId(projectId)) { "Choose a project on this computer." } + require(kind in setOf("shell", "agent")) + require(if (kind == "agent") Agent.of(agentId) != null else agentId == null && accountId == null) + require(accountId == null || Regex("^[A-Za-z0-9_-]{1,200}$").matches(accountId)) + require(title == null || title.isNotBlank() && title.length <= 120 && ManagedSessions.safeText(title)) + require(cols in 2..500 && rows in 2..500) + } + fun params(creationId: String): JsonObject = buildJsonObject { + put("creationId", creationId); put("projectId", projectId); put("kind", kind) + agentId?.let { put("agentId", it) }; accountId?.let { put("accountId", it) } + title?.let { put("title", it) }; put("cols", cols); put("rows", rows) + } +} + +/** Frozen before dispatch; reconnect must never manufacture a replacement nonce or instance. */ +data class PreparedManagedSession( + val profile: String, + val hostInstance: String, + val nonce: String, + val issuedAt: Long, + val creationId: String, + val choice: ManagedSessionChoice, + val advertisement: String, +) { + init { + require(SshActionsScripts.validProfile(profile)) + require(ManagedSessions.UUID.matches(hostInstance) && ManagedSessions.UUID.matches(nonce) && ManagedSessions.UUID.matches(creationId)) + require(issuedAt > 0 && advertisement.toByteArray(Charsets.UTF_8).size <= 16 * 1024) + } + fun wireRequest(): String = buildJsonObject { + put("version", 1); put("instance", hostInstance); put("nonce", nonce); put("issuedAt", issuedAt) + put("method", ManagedSessions.METHOD); put("params", choice.params(creationId)) + }.toString().also { require(it.toByteArray(Charsets.UTF_8).size <= SshActionsScripts.MAX_BYTES) } + fun toJson(): JsonObject = buildJsonObject { + put("profile", profile); put("hostInstance", hostInstance); put("nonce", nonce); put("issuedAt", issuedAt) + put("creationId", creationId); put("params", choice.params(creationId)); put("advertisement", advertisement) + } +} + +/** A host-created, registered generation; possession never authorizes creation or relaunch. */ +data class ManagedSessionReceipt( + val creationId: String, + val nodeId: String, + val projectId: String, + val hostInstance: String, + val socket: String, + val session: String, + val paneId: String, + val panePid: Int, + val paneBirth: String, + val sessionCreated: String, +) { + fun toJson(): JsonObject = buildJsonObject { + put("version", 1); put("creationId", creationId); put("nodeId", nodeId); put("projectId", projectId) + put("hostInstance", hostInstance); put("socket", socket); put("session", session) + put("paneId", paneId); put("panePid", panePid); put("paneBirth", paneBirth); put("sessionCreated", sessionCreated) + } +} + +data class ManagedSessionAdoption(val request: PreparedManagedSession, val receipt: ManagedSessionReceipt) { + init { ManagedSessions.receipt(receipt.toJson(), request) } + fun toJson(): JsonObject = buildJsonObject { put("request", request.toJson()); put("receipt", receipt.toJson()) } +} + +/** A confirmed refusal before mutation permits a new choice; an unanswered write does not. */ +class ManagedSessionRefusedException(message: String) : HostException(message) + +object ManagedSessions { + const val METHOD = "sessions.createManagedV1" + val UUID = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + private val NODE = Regex("^term-[a-z0-9]+-[a-z0-9]{1,16}$") + private val PANE = Regex("^%[0-9]{1,12}$") + private val LINUX_BIRTH = Regex("^linux:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]{1,20}$") + private val DARWIN_BIRTH = Regex("^darwin:[A-Za-z]{3} [A-Za-z]{3} +[0-9]{1,2} [0-9]{2}:[0-9]{2}:[0-9]{2} [0-9]{4}$") + fun safeText(value: String): Boolean = value.none { it.code < 32 || it.code in 127..159 || it.code in 0x2028..0x2029 } + fun validId(value: String): Boolean = value.isNotEmpty() && value.length <= 200 && safeText(value) + private fun JsonObject.string(name: String): String = getValue(name).jsonPrimitive.also { require(it.isString) }.content + private fun JsonObject.number(name: String): Long = getValue(name).jsonPrimitive.also { require(!it.isString) }.long + private fun JsonObject.optional(name: String): String? = get(name)?.let { require(it != JsonNull); it.jsonPrimitive.also { p -> require(p.isString) }.content } + fun choice(o: JsonObject): ManagedSessionChoice = ManagedSessionChoice(o.string("projectId"), o.string("kind"), o.optional("agentId"), o.optional("accountId"), o.optional("title"), o.number("cols").toIntExact(), o.number("rows").toIntExact()) + fun prepared(o: JsonObject): PreparedManagedSession { + val creation = o.string("creationId") + val params = o.getValue("params").jsonObject + require(params.string("creationId") == creation) + return PreparedManagedSession(o.string("profile"), o.string("hostInstance"), o.string("nonce"), o.number("issuedAt"), creation, choice(params), o.string("advertisement")) + } + fun receipt(element: JsonElement, request: PreparedManagedSession): ManagedSessionReceipt { + val o = element.jsonObject + require(o.number("version") == 1L) + val r = ManagedSessionReceipt(o.string("creationId"), o.string("nodeId"), o.string("projectId"), o.string("hostInstance"), o.string("socket"), o.string("session"), o.string("paneId"), o.number("panePid").toIntExact(), o.string("paneBirth"), o.string("sessionCreated")) + require(r.creationId == request.creationId && r.projectId == request.choice.projectId && r.hostInstance == request.hostInstance) + require(NODE.matches(r.nodeId) && !r.nodeId.startsWith("phone-")) + require(r.socket == TmuxNames.SOCKET && r.session == TmuxNames.sessionName(r.nodeId)) + require(PANE.matches(r.paneId) && r.panePid > 0) + require(r.paneBirth.length <= 128 && safeText(r.paneBirth) && (LINUX_BIRTH.matches(r.paneBirth) || DARWIN_BIRTH.matches(r.paneBirth))) + require(Regex("^[1-9][0-9]{0,19}$").matches(r.sessionCreated)) + return r + } + fun adoption(o: JsonObject): ManagedSessionAdoption { + val request = prepared(o.getValue("request").jsonObject) + return ManagedSessionAdoption(request, receipt(o.getValue("receipt"), request)) + } + private fun Long.toIntExact(): Int = toInt().also { require(it.toLong() == this) } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt new file mode 100644 index 0000000000..5563975c85 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt @@ -0,0 +1,163 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.QuestionChoices + +/** + * The Inbox card actions (docs/mobile-usage-inbox.md "Quick approve", docs/hook-reply-approvals.md), + * shared by every transport: + * + * 1. RE-READ the status before acting, and act only if what the card asks is still open (a card + * can be minutes old; typing `1` into a pane that moved on is typing into whatever is there now); + * 2. a held hook-reply approval (it carries a `pendingId`) is answered DETERMINISTICALLY — the + * relay verb or the answer file — never with keys: while the hook holds the request, the prompt + * is not on screen and a keystroke would land in the agent's composer; + * 3. keys are typed only for a claude approval with NO ticket (`1` allow / Esc deny), because only + * claude's prompt layout is known, and for a single-select question (its digit); anything else is + * "open the session" (a multi-select question's options are shown, not answered: [QuestionChoices]). + * + * What "still open" means differs by path, on purpose (audit A38). KEYS need the node to show + * exactly the prompt they answer: `1` on a node whose AskUserQuestion picker is on screen picks + * option 1 of the question instead of approving anything, so a keyed approval needs [AgentState.BLOCKED] + * and a keyed question [AgentState.WAITING]. A TICKET does not: the desktop publishes a concurrent + * approval (a subagent's permission) with its `pendingId` while the node keeps the parent's WAITING + * badge for a held question (`recordAgentEvent`, src/core/agent-status-mirror.ts; pinned by + * src/core/pending-question.test.ts), so a ticketed answer is judged by its CARD, and the host + * refuses it ("gone") once the hook's hold has ended. + * + * Keys also need the fresh feed to still LIST the card, unresolved (the review of audit A25). A key + * carries no identity of the prompt it answers, so a card the feed no longer lists is no evidence + * that the prompt on screen is still its own: the desktop drops every event after 6 hours and trims + * its feed to 50 events, keeping only the newest unresolved ask of each node, so a card settled long + * ago (or an older ask the node has since moved past) disappears while the node blocks on a NEWER + * prompt in the same state. A notification left in the shade, or a card on a screen that is not + * re-listed, outlives that, and its `1` would approve the newer prompt. Such a card goes to the + * session ([Result.OPEN_SESSION]): whether it was handled is not known, and the session shows what is + * really on screen. A ticket keeps its node-state fallback: the host refuses a ticket whose hold ended. + * + * A v2 hook ticket exposes only concrete host-supplied allow-rule suggestions and complete question + * schemas. These use the original held JSON, not numbered prompt choices. Older and unheld prompts + * retain the legacy paths above; unsupported schema or an expired hold opens the session. + */ +object QuickActions { + /** + * [EXPIRED]: the hook's hold ended before the answer arrived (audit A06) and the request is still + * open, so the prompt is on screen in the session — open it (with an explanation). + */ + enum class Result { SENT, ALREADY_HANDLED, OPEN_SESSION, EXPIRED } + + suspend fun rememberApproval(conn: HostConnection, event: InboxEvent, suggestionIndex: Int): Result { + if (event.kind != InboxKind.APPROVAL || event.pendingId == null || event.permissionSuggestions.none { it.index == suggestionIndex }) + return Result.OPEN_SESSION + val fresh = freshCard(conn.listProjects(), event) ?: return Result.OPEN_SESSION + if (fresh.resolved) return Result.ALREADY_HANDLED + if (fresh.pendingId != event.pendingId || fresh.permissionSuggestions != event.permissionSuggestions) return Result.OPEN_SESSION + return hookOutcome(conn, event, conn.rememberApproval(event, suggestionIndex)) + } + + suspend fun answerQuestions(conn: HostConnection, event: InboxEvent, selections: List>): Result { + if (event.kind != InboxKind.QUESTION || event.questionPendingId == null || + !dev.nodeterm.protocol.model.HookReplies.validSelections(event.questions, selections)) return Result.OPEN_SESSION + val fresh = freshCard(conn.listProjects(), event) ?: return Result.OPEN_SESSION + if (fresh.resolved) return Result.ALREADY_HANDLED + if (fresh.questionPendingId != event.questionPendingId || fresh.questions != event.questions) return Result.OPEN_SESSION + return hookOutcome(conn, event, conn.answerQuestions(event, selections)) + } + + private suspend fun hookOutcome(conn: HostConnection, event: InboxEvent, outcome: ApprovalOutcome): Result = when (outcome) { + ApprovalOutcome.SENT -> Result.SENT + ApprovalOutcome.GONE -> if (freshCard(conn.listProjects(), event)?.resolved == true) Result.ALREADY_HANDLED else Result.EXPIRED + ApprovalOutcome.ALREADY_HANDLED, ApprovalOutcome.UNSUPPORTED -> Result.OPEN_SESSION + } + + suspend fun answerApproval(conn: HostConnection, event: InboxEvent, allow: Boolean): Result { + if (event.kind != InboxKind.APPROVAL) return Result.OPEN_SESSION + if (event.pendingId != null) return answerTicket(conn, event, allow) + keysRefusal(conn, event, AgentState.BLOCKED)?.let { return it } + if (!answersApproval(event)) return Result.OPEN_SESSION + return typeOrOpen(conn, event, if (allow) "1" else "\u001b") + } + + /** + * Whether [answerApproval] can answer [event] from outside its session at all: a held hook-reply + * ticket, or a claude prompt (the only prompt layout known). Anything else goes to the session. + * The Inbox notification offers Approve / Deny by this rule (audit A25), so it never offers an + * answer this path would turn into "open the session". + */ + fun answersApproval(event: InboxEvent): Boolean = + event.kind == InboxKind.APPROVAL && (event.pendingId != null || event.agentId == "claude") + + /** + * Legacy unheld AskUserQuestion: choices are digits on screen. Only a + * question [QuestionChoices] lists as [QuestionChoices.Answer] is typed, the rule the Inbox card + * draws its buttons by. A multi-select question is shown read-only and answered in the session + * (audit A57): how its picker toggles and submits has not been measured, so it gets no keys. + */ + suspend fun answerQuestion(conn: HostConnection, event: InboxEvent, optionIndex: Int): Result { + val choices = QuestionChoices.of(event) as? QuestionChoices.Answer ?: return Result.OPEN_SESSION + if (optionIndex !in choices.rows.indices || optionIndex > 8) return Result.OPEN_SESSION + keysRefusal(conn, event, AgentState.WAITING)?.let { return it } + return typeOrOpen(conn, event, (optionIndex + 1).toString()) + } + + /** A held hook-reply approval: answered through its ticket or not at all — never with keys. */ + private suspend fun answerTicket(conn: HostConnection, event: InboxEvent, allow: Boolean): Result { + if (!ticketStillOpen(conn.listProjects(), event)) return Result.ALREADY_HANDLED + if (conn.capabilities.answerApprovals) { + when (conn.answerApproval(event, allow)) { + ApprovalOutcome.SENT -> return Result.SENT + // Nothing was written. If the request is STILL open, the hold timed out and the prompt + // is on screen now: "already handled" would be false — send the user to the session. + ApprovalOutcome.GONE, ApprovalOutcome.ALREADY_HANDLED -> + return if (ticketStillOpen(conn.listProjects(), event)) Result.EXPIRED else Result.ALREADY_HANDLED + ApprovalOutcome.UNSUPPORTED -> Unit + } + } + // A ticketed approval we could not answer deterministically: its prompt is not on screen + // while the hook holds it, so keys are wrong. Send the user to the session instead. + return Result.OPEN_SESSION + } + + /** Keys the host could not deliver are not "sent": the prompt is on screen, so open it. A + * [NeedsRelayException] propagates — the caller retries through the relay. */ + private suspend fun typeOrOpen(conn: HostConnection, event: InboxEvent, keys: String): Result = try { + conn.sendKeys(event.nodeId, keys) + Result.SENT + } catch (e: NeedsRelayException) { + throw e + } catch (_: HostException) { + Result.OPEN_SESSION + } + + /** + * The KEYS gate: null when the keys may be typed, else what to answer instead. The node must show + * exactly [expected] (else [Result.ALREADY_HANDLED]), and the fresh feed must list the card, + * unresolved: a settled card is [Result.ALREADY_HANDLED], and one the feed no longer lists is + * [Result.OPEN_SESSION], since the prompt on screen may be a newer one (see the class comment). + */ + private suspend fun keysRefusal(conn: HostConnection, event: InboxEvent, expected: AgentState): Result? { + val snap = conn.listProjects() + val status = snap.statusOf(event.nodeId) ?: return Result.ALREADY_HANDLED + if (status.state != expected) return Result.ALREADY_HANDLED + val fresh = freshCard(snap, event) ?: return Result.OPEN_SESSION + return if (fresh.resolved) Result.ALREADY_HANDLED else null + } + + /** + * The TICKET gate: the card decides, whatever the node's badge says (a concurrent approval rides a + * WAITING node). Only when the fresh feed no longer lists the card (the host trims its feed, and a + * snapshot may carry none) is the node's state the remaining evidence — either needs-you state, + * since nothing is typed on this path and the host itself refuses a hold that has ended. + */ + private fun ticketStillOpen(snap: ProjectsSnapshot, event: InboxEvent): Boolean { + freshCard(snap, event)?.let { return !it.resolved } + val state = snap.statusOf(event.nodeId)?.state + return state == AgentState.BLOCKED || state == AgentState.WAITING + } + + private fun freshCard(snap: ProjectsSnapshot, event: InboxEvent): InboxEvent? = + snap.status?.inbox?.events?.firstOrNull { it.id == event.id } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayApprovalGate.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayApprovalGate.kt new file mode 100644 index 0000000000..2c510b325f --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayApprovalGate.kt @@ -0,0 +1,115 @@ +package dev.nodeterm.protocol.host + +/** The desktop has not pinned this phone, and the caller asked not to trigger its approval dialog. */ +class RelayApprovalRequiredException : + HostException("This phone is not approved on your computer yet. Open the app and connect once to approve it.") + +/** The approval dialog was dismissed (Deny) — the host closed the handshake while we waited. */ +class RelayApprovalRefusedException : + HostException("Your computer did not approve this phone (the request was declined or dismissed).") + +/** Nobody answered the approval dialog before the phone stopped waiting. */ +class RelayApprovalTimeoutException : + HostException("Nobody approved this phone on your computer in time.") + +/** + * Who may dial a computer's relay leg, and when (audit A05/A17/A23/A30). + * + * The desktop raises its SAS approval dialog the moment a never-pinned phone completes the relay + * handshake (standing-host.ts `onPeerReady`) — BEFORE the phone learns it is awaiting approval, so + * "abort on awaiting" cannot keep the dialog off an unattended desktop. The only prevention is not + * dialing. Two rules: + * + * 1. **Background dials only a computer that approves this phone without its dialog.** A background + * check (the Inbox worker) dials the relay only when [isApproved] says so: a FOREGROUND relay + * connect has already succeeded (the computer pinned this phone's key), or pairing said + * `relayApproved` ([dev.nodeterm.protocol.pairing.PairingResult.relayApproved]). The second covers + * a key the computer has not pinned yet and has never seen on the relay — a phone paired while + * remote access was off, whose background check may then be its FIRST relay handshake — because + * the pairing recorded the key and the computer's standing host pins it on that handshake without + * the dialog (audit A07-late; the desktop holds the phone's first request until it has decided, + * so the check is not told it is awaiting approval meanwhile). Either way the check asks the + * connector to give up rather than wait if approval turns out to be needed (the pin was revoked, + * the pairing removed, or a desktop that predates the late pin), and forgets the approval so it + * does not try again. + * 2. **A refused or unanswered approval is not retried on its own.** After Deny, or nobody + * answering, the automatic dials (the 8 s poll, reconnect-after-drop) skip the relay leg with the + * reason, until the user asks again (Try again / opening the computer). Before this, the phone + * re-dialed about 8 s after Deny and the dialog came straight back. + * + * Pure (state in memory + the two injected accessors for the persisted flag), so it is JVM-tested. + */ +class RelayApprovalGate( + private val isApproved: (hostId: String) -> Boolean, + private val setApproved: (hostId: String, approved: Boolean) -> Unit +) { + /** Why a dial is happening. */ + enum class Trigger { + /** The user asked (Try again, Refresh, opening the computer): releases a hold. */ + USER, + /** The app on its own, in the foreground (the poll, a reconnect after a drop). */ + AUTO, + /** + * Nobody is looking at the app: the Inbox worker, or an answer given from a notification (audit + * A25), where the user tapped but would not see an approval code either. + */ + BACKGROUND + } + + sealed interface Decision { + /** Dial. With [requireApproved], abort instead of waiting for an approval dialog. */ + data class Dial(val requireApproved: Boolean) : Decision + data class Skip(val reason: String) : Decision + } + + private val holds = HashMap() + + @Synchronized + fun decide(hostId: String, trigger: Trigger): Decision { + if (trigger == Trigger.USER) holds.remove(hostId) + holds[hostId]?.let { return Decision.Skip(it) } + if (trigger == Trigger.BACKGROUND) { + return if (isApproved(hostId)) Decision.Dial(requireApproved = true) + else Decision.Skip(NOT_APPROVED_IN_BACKGROUND) + } + return Decision.Dial(requireApproved = false) + } + + /** A relay connect succeeded: the host serves us, so our key is pinned there. */ + @Synchronized + fun onConnected(hostId: String) { + holds.remove(hostId) + setApproved(hostId, true) + } + + /** A relay connect failed with [error]. */ + @Synchronized + fun onFailed(hostId: String, error: Throwable) { + when (error) { + is RelayApprovalRefusedException -> { + holds[hostId] = error.message + " Tap Try again to ask again." + setApproved(hostId, false) + } + is RelayApprovalTimeoutException -> { + holds[hostId] = error.message + " Tap Try again when you are at the computer." + setApproved(hostId, false) + } + is RelayApprovalRequiredException -> setApproved(hostId, false) + else -> Unit // network, relay, backend: ordinary failures, retried as before + } + } + + /** The current hold for [hostId], if automatic dials are suspended. */ + @Synchronized + fun hold(hostId: String): String? = holds[hostId] + + @Synchronized + fun forget(hostId: String) { + holds.remove(hostId) + } + + companion object { + const val NOT_APPROVED_IN_BACKGROUND = + "Not checked in the background: this phone has not been approved on the computer through the relay yet." + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayConnector.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayConnector.kt new file mode 100644 index 0000000000..724a051e02 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayConnector.kt @@ -0,0 +1,119 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.pairing.RelayPairingProof +import dev.nodeterm.protocol.relay.OkHttpRelayTransport +import dev.nodeterm.protocol.relay.RelaySocket +import dev.nodeterm.protocol.relay.RelayTransportFactory +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeoutOrNull + +/** Where a relay connect is, for the UI. */ +sealed interface RelayConnectStatus { + data object MintingToken : RelayConnectStatus + data object Handshaking : RelayConnectStatus + /** The handshake completed; the desktop must approve this phone (first connect only — pin-once). */ + data class AwaitingApproval(val sas: String) : RelayConnectStatus +} + +/** The host message the standing host answers every request with until it approves this device. */ +const val AWAITING_APPROVAL_MESSAGE = "Awaiting host approval." + +/** + * Connect to a paired computer over the relay and wait until it serves us. + * + * The standing phone host is PIN-ONCE (standing-host.ts): the first connect from this phone's box + * key puts the SAS approval dialog on the desktop; once approved the key is pinned and every later + * connect auto-approves silently. Until then every request answers [AWAITING_APPROVAL_MESSAGE], so + * approval is observed by retrying `projects.list` — which is also the first thing the UI needs. + */ +object RelayConnector { + class Connected(val connection: RelayHostConnection, val sas: String, val first: ProjectsSnapshot) + + suspend fun connect( + relayUrl: String, + token: String, + deviceKeys: BoxKeyPair, + hostPublicKeyB64: String, + onStatus: (RelayConnectStatus) -> Unit = {}, + transport: RelayTransportFactory = OkHttpRelayTransport.factory(), + handshakeTimeoutMs: Long = 20_000, + approvalTimeoutMs: Long = 5 * 60_000, + approvalPollMs: Long = 1_500, + /** Give up with [RelayApprovalRequiredException] instead of waiting for an approval dialog + * (a background check: nobody is there to compare the code). See [RelayApprovalGate]. */ + requireApproved: Boolean = false, + /** Optional proof using only the captured pairing and the retained SSH seed. */ + legacyPairing: RelayPairingProof.Context? = null, + ): Connected { + onStatus(RelayConnectStatus.Handshaking) + val ready = CompletableDeferred() + val closed = CompletableDeferred() + val conn = RelayHostConnection.open( + build = { listener -> RelaySocket(relayUrl, token, deviceKeys, hostPublicKeyB64, transport, listener) }, + onReady = { sas -> ready.complete(sas) } + ) + conn.setOnClosed { reason -> closed.complete(reason) } + try { + val sas = withTimeoutOrNull(handshakeTimeoutMs) { + kotlinx.coroutines.selects.select { + ready.onAwait { it } + closed.onAwait { reason -> + throw HostException( + "The relay closed the connection before your computer answered" + + (reason?.let { " ($it)" } ?: "") + + ". Is remote access on in nodeterm → Settings → Phone?" + ) + } + } + } ?: throw HostException("Your computer did not answer through the relay. Is nodeterm running with remote access on?") + + val deadline = System.currentTimeMillis() + approvalTimeoutMs + var announced = false + while (true) { + // The standing host drops the handshake when the human presses Deny (or dismisses + // the dialog): once we have shown the code, a close IS the refusal. + if (closed.isCompleted) { + if (announced) throw RelayApprovalRefusedException() + throw HostException("The connection closed while waiting for approval.") + } + try { + val snapshot = conn.listProjects() + announced = false // Approval is proved; a later network close is not Deny. + // Successful listing proves approval. An old/unprovable host still browses; + // cancellation keeps the ordinary dial cleanup and lifetime guarantees. + legacyPairing?.let { conn.proveLegacyPairing(it) } + // RPC waiters can resume before onClosed completes our deferred. Read the + // socket state too, so an optional proof cannot hand off a closed connection. + if (!conn.isReady || closed.isCompleted) { + throw HostException("The relay closed the connection while opening this computer.") + } + conn.setOnClosed(null) + return Connected(conn, sas, snapshot) + } catch (e: HostException) { + if (e.message != AWAITING_APPROVAL_MESSAGE) { + // A request cut short by the close IS the refusal too (same reasoning). + if (announced && withTimeoutOrNull(1_000) { closed.await() } != null || announced && closed.isCompleted) { + throw RelayApprovalRefusedException() + } + throw e + } + if (requireApproved) throw RelayApprovalRequiredException() + if (!announced) { + announced = true + onStatus(RelayConnectStatus.AwaitingApproval(sas)) + } + if (System.currentTimeMillis() > deadline) { + throw RelayApprovalTimeoutException() + } + delay(approvalPollMs) + } + } + } catch (e: Throwable) { + conn.close() + throw e + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt new file mode 100644 index 0000000000..d9a8c70bba --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt @@ -0,0 +1,484 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.objects +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.model.J.strings +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.KanbanLabel +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.TerminalHistory +import dev.nodeterm.protocol.pairing.RelayPairingProof +import dev.nodeterm.protocol.relay.Frame +import dev.nodeterm.protocol.relay.Framing +import dev.nodeterm.protocol.relay.Op +import dev.nodeterm.protocol.relay.RelaySocket +import dev.nodeterm.protocol.relay.RelaySocketListener +import dev.nodeterm.protocol.relay.RpcException +import dev.nodeterm.protocol.relay.SnapshotReassembler +import dev.nodeterm.protocol.ssh.LanReport +import dev.nodeterm.protocol.ssh.PhoneTerminals +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.delay +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * [HostConnection] over the standing phone host's relay dialect — `createHostHandlers` in + * `src/main/remote/host-service.ts` is the other end, verb for verb: + * + * `projects.list` → `{output, lan?}` · `pty.attach {nodeId, cols, rows}` → `{streamId, fresh}` then + * Snapshot Start, Chunk…, End and Output frames · `pty.kill|destroy|scroll {streamId…}` · + * `node.wake|refresh|rename {nodeId, title?}` · `projects.ensureBoard|setCardColumn| + * editCardLabels|registerNode` · `git.*` · Input/Resize frames up, Output/Resized/Error down. + * + * `resizedFrames` is deliberately NOT sent on attach, exactly like the iOS app: a phone is a + * CEILING on the shared pty (a session never grows wider than its screen). An `OP.Resized` still + * arrives when a desktop viewer sized the session, and is surfaced so the UI can offer "fit". + */ +class RelayHostConnection private constructor() : HostConnection, RelaySocketListener { + private lateinit var socket: RelaySocket + private val streams = ConcurrentHashMap() + @Volatile private var onChanged: (() -> Unit)? = null + @Volatile private var onClosed: ((String?) -> Unit)? = null + @Volatile private var readyListener: ((String) -> Unit)? = null + + override val kind = TransportKind.RELAY + override val capabilities = LegRouting.RELAY_CAPABILITIES + /** Socket state changes before pending RPCs resume and before the close listener runs. */ + internal val isReady: Boolean get() = socket.isReady + + private inner class Stream(val id: Long, override val fresh: Boolean, val sink: TerminalSink, val scrollV1: Boolean) : TerminalStream { + private val snapshot = SnapshotReassembler() + private var outSeq = 0L + @Volatile private var composedRetired = false + private var scrollViewId: String? = null + private var scrollViewEpoch = 0L + override fun clearScrollView() { synchronized(this) { scrollViewId = null; scrollViewEpoch++ } } + override fun retireComposed() { composedRetired = true } + + fun accept(frame: Frame) { + when (frame.op) { + Op.SNAPSHOT_START, Op.SNAPSHOT_CHUNK -> snapshot.accept(frame) + Op.SNAPSHOT_END -> snapshot.accept(frame)?.let { if (it.isNotEmpty()) sink.onPaint(it) } + Op.OUTPUT -> sink.onOutput(frame.payload) + Op.RESIZED -> Framing.readSize(frame.payload)?.let { (c, r) -> sink.onResized(c, r) } + Op.ERROR -> { + streams.remove(id) + val code = J.obj(J.parse(String(frame.payload, Charsets.UTF_8)))?.l("exitCode")?.toInt() + sink.onExit(code) + } + } + } + + override fun write(text: String) { + synchronized(this) { socket.sendFrame(Op.INPUT, id, outSeq++, text.toByteArray(Charsets.UTF_8)) } + } + + override fun resize(cols: Int, rows: Int) { + synchronized(this) { socket.sendFrame(Op.RESIZE, id, outSeq++, Framing.sizePayload(cols, rows)) } + } + + override suspend fun scroll(up: Boolean, lines: Int) { + // Relay backend identity is unknown. Never assume an older host is tmux and inject wheels. + scrollView(up, lines) + } + + override suspend fun scrollView(up: Boolean, lines: Int): TerminalScrollView.Result { + if (lines !in 1..20 || composedRetired || streams[id] !== this) + return TerminalScrollView.Result.Refused("This terminal is no longer ready to scroll.") + if (!scrollV1) return TerminalScrollView.Result.Refused("History scrolling needs an updated nodeterm on the computer.") + val (epoch, viewId) = synchronized(this) { scrollViewEpoch to scrollViewId } + val body = try { + call("pty.scrollV1", buildJsonObject { + put("streamId", id); put("dir", if (up) "up" else "down"); put("lines", lines) + viewId?.let { put("viewId", it) } + }) + } catch (_: HostUnansweredException) { + clearScrollView() + return TerminalScrollView.Result.Uncertain("Scroll could not be confirmed. Check the terminal before scrolling again.") + } catch (_: HostException) { + clearScrollView() + return TerminalScrollView.Result.Uncertain("Scrolling failed without a confirmed result. Scroll was not repeated.") + } + val result = TerminalScrollView.parse(body) + ?: TerminalScrollView.Result.Uncertain("The computer returned an invalid history page. Scroll was not repeated.") + return synchronized(this) { + if (composedRetired || streams[id] !== this || epoch != scrollViewEpoch) { + // Closing a viewer cannot turn an already-written wheel into a safe refusal. + if (result is TerminalScrollView.Result.Input || result is TerminalScrollView.Result.Uncertain) + TerminalScrollView.Result.Uncertain("This scroll may have reached the terminal before the view closed. Scroll was not repeated.") + else TerminalScrollView.Result.Refused("This history view was closed.") + } + else { + scrollViewId = (result as? TerminalScrollView.Result.History)?.viewId + result + } + } + } + + override suspend fun submitComposed(input: ComposedInput): ComposedInputResult { + if (!input.valid()) return ComposedInputResult.refused("Send is too large or invalid. The draft was kept.") + if (composedRetired || streams[id] !== this) return ComposedInputResult.refused() + val normalized = input.normalized() + val fields = buildJsonObject { + put("kind", if (normalized is ComposedInput.Control) "control" else "paste") + put("text", when (normalized) { is ComposedInput.Paste -> normalized.text; is ComposedInput.Control -> normalized.text }) + put("enter", normalized is ComposedInput.Paste && normalized.enter) + } + val answer = try { + J.obj(call("pty.submitComposed", buildJsonObject { put("streamId", id); put("input", fields) })) + } catch (error: HostUnansweredException) { + return ComposedInputResult.uncertain() + } catch (_: HostException) { + // A legacy unknown method is an explicit refusal, never raw input or node.sendKeys. + return ComposedInputResult.refused("Send is unavailable on this host or terminal. Update nodeterm on the computer or use its terminal; the draft was kept.") + } + if (composedRetired || streams[id] !== this) return ComposedInputResult.uncertain() + return when (answer?.s("status")) { + "delivered" -> ComposedInputResult.DELIVERED + "refused" -> ComposedInputResult.refused(answer.s("message") ?: "Send was refused. The draft was kept.") + else -> ComposedInputResult.uncertain() + } + } + + override suspend fun searchHistory(query: String): TerminalHistory.Result { + if (!TerminalHistory.validQuery(query)) throw HostException("Enter a single-line search of 1–256 characters.") + if (streams[id] !== this) throw HostException("This terminal is no longer attached.") + val body = call("pty.historySearch", buildJsonObject { put("streamId", id); put("query", query) }) + if (streams[id] !== this) throw HostException("This terminal detached while its history was searched.") + return TerminalHistory.parseRelay(body, query) ?: throw HostException("The host returned an invalid history search result.") + } + + override suspend fun detach() { + clearScrollView() + streams.remove(id) + runCatching { call("pty.kill", buildJsonObject { put("streamId", id) }) } + } + + /** + * [detach] for a stream nobody was handed: its attach was cancelled while the request was on + * the wire (audit A40). Non-suspending and quick, since it runs as a continuation's + * cancellation handler; the host's answer is not waited for. + */ + fun abandon() { + clearScrollView() + streams.remove(id) + socket.request("pty.kill", buildJsonObject { put("streamId", id) }) {} + } + + override suspend fun endSession() { + try { + call("pty.destroy", buildJsonObject { put("streamId", id) }) + } finally { + streams.remove(id) + } + } + } + + // ---- RelaySocketListener ------------------------------------------------------------------ + + override fun onReady(sas: String) { + readyListener?.invoke(sas) + } + + override fun onNotify(method: String, params: JsonElement?) { + // `canvas:state` is pushed on approval and on every host canvas change: a cheap "re-list". + if (method == "canvas:state") onChanged?.invoke() + } + + override fun onFrame(frame: Frame) { + streams[frame.streamId]?.accept(frame) + } + + override fun onClosed(reason: String?) { + val live = streams.values.toList() + streams.clear() + for (s in live) s.sink.onExit(null) + onClosed?.invoke(reason) + } + + // ---- RPC plumbing --------------------------------------------------------------------- + + private suspend fun call(method: String, params: JsonElement? = null, timeoutMs: Long = RelaySocket.RPC_TIMEOUT_MS): JsonElement? = try { + socket.call(method, params, timeoutMs) + } catch (e: RpcException) { + throw hostException(e) + } + + override suspend fun listProjects(): ProjectsSnapshot { + val body = J.obj(call("projects.list")) ?: return ProjectsSnapshot.EMPTY + // `lan` (A74-refresh) rides beside the blob, never inside it: the computer's current LAN + // address and SSH host keys, which only this authenticated channel may hand the phone. + return ProjectsParser.parseBlob(body.s("output") ?: "").copy(lan = LanReport.parse(body["lan"])) + } + + /** Additive approved-relay migration; never changes this phone's tokens or pairing record. */ + suspend fun proveLegacyPairing(context: RelayPairingProof.Context): RelayPairingProof.Outcome = + RelayPairingProof.afterApprovedListing(context, rpc = { method, params -> call(method, params, RelayPairingProof.TIMEOUT_MS) }) + + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream { + // The relay creates an unknown id on the desktop socket. Phone-owned shells never live + // there: even reconnect/route fallback must fail before sending a creating RPC. + if (PhoneTerminals.validId(nodeId)) throw HostException("This phone terminal opens over SSH. Choose your network route to reopen it.") + return suspendCancellableCoroutine { cont -> + val params = buildJsonObject { + put("nodeId", nodeId) + put("cols", cols) + put("rows", rows) + if (create != null) { + put("projectId", create.projectId) + create.accountId?.let { put("accountId", it) } + create.agentId?.let { put("agentId", it) } + } + } + socket.request("pty.attach", params) { r -> + r.fold( + onSuccess = { body -> + val o = J.obj(body) + val streamId = o?.l("streamId") + if (streamId == null) { + cont.resumeWithException(HostException("The host did not open a terminal stream.")) + } else { + // Registered HERE, on the reader thread, before the snapshot frames that + // follow the response are processed. + val s = Stream(streamId, o.b("fresh") == true, sink, (o["scrollV1"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true) + streams[streamId] = s + // A caller cancelled while the request was on the wire never gets this + // stream, and the host has already reserved it (a viewer on the node: an + // Eco shield and a size ceiling on the desktop). Let it go, or nothing + // ever does (audit A40). Also covers a cancel that lands after this + // resume but before the caller runs again. + cont.resume(s) { _, stream, _ -> stream.abandon() } + } + }, + onFailure = { cont.resumeWithException(HostException(it.message ?: "Attach failed.")) } + ) + } + } + } + + override suspend fun wake(nodeId: String) { + call("node.wake", buildJsonObject { put("nodeId", nodeId) }) + } + + override suspend fun refresh(nodeId: String) { + call("node.refresh", buildJsonObject { put("nodeId", nodeId) }) + } + + override suspend fun rename(nodeId: String, title: String) { + call("node.rename", buildJsonObject { + put("nodeId", nodeId) + put("title", title) + }) + } + + override suspend fun ensureBoard(projectId: String): List? { + val o = J.obj(call("projects.ensureBoard", buildJsonObject { put("projectId", projectId) })) ?: return null + val cols = o["columns"] as? JsonArray ?: return null + return cols.mapNotNull { J.obj(it) }.mapNotNull { c -> + val id = c.s("id") ?: return@mapNotNull null + KanbanColumn(id, c.s("title") ?: "", c.s("color")) + } + } + + override suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean { + val o = J.obj(call("projects.setCardColumn", buildJsonObject { + put("projectId", projectId) + put("nodeId", nodeId) + // null is REAL here: the virtual Ungrouped column. + put("columnId", columnId?.let(::JsonPrimitive) ?: JsonNull) + })) + return o?.b("moved") == true + } + + override suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? { + val o = J.obj(call("projects.editCardLabels", buildJsonObject { + put("projectId", projectId) + put("nodeId", nodeId) + if (edit.add.isNotEmpty()) put("add", JsonArray(edit.add.map(::JsonPrimitive))) + if (edit.remove.isNotEmpty()) put("remove", JsonArray(edit.remove.map(::JsonPrimitive))) + if (edit.create.isNotEmpty()) put("create", JsonArray(edit.create.map { (name, color) -> + buildJsonObject { + put("name", name) + put("color", color) + } + })) + })) ?: return null + if (o["labels"] !is JsonArray) return null + return LabelEditResult( + edited = o.b("edited") == true, + labels = o.objects("labels").mapNotNull { l -> + val id = l.s("id") ?: return@mapNotNull null + KanbanLabel(id, l.s("name") ?: "", l.s("color") ?: "default") + }, + cardLabelIds = o.strings("cardLabelIds") + ) + } + + override suspend fun registerNode(projectId: String, node: NewNode): Boolean { + val o = J.obj(call("projects.registerNode", buildJsonObject { + put("projectId", projectId) + put("node", buildJsonObject { + put("id", node.id) + node.title?.let { put("title", it) } + node.agentId?.let { put("agentId", it) } + node.accountId?.let { put("accountId", it) } + }) + })) + return o?.b("registered") == true + } + + /** `approvals.answer` (host-service.ts `HostInboxOps`). A desktop that predates the verb answers + * "not served", which is [ApprovalOutcome.UNSUPPORTED] — never a guess. */ + override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome { + val pendingId = event.pendingId ?: return ApprovalOutcome.UNSUPPORTED + val body = try { + J.obj(call("approvals.answer", buildJsonObject { + put("nodeId", event.nodeId) + put("pendingId", pendingId) + put("decision", if (allow) "allow" else "deny") + })) + } catch (e: HostException) { + if (e.message?.contains("not served") == true || e.message?.startsWith("Unknown method") == true) { + return ApprovalOutcome.UNSUPPORTED + } + throw e + } + if (body?.b("answered") == true) return ApprovalOutcome.SENT + // `reason` (additive, audit A35): `gone` = the hold ended; `failed` = the host could not write. + return when (body?.s("reason")) { + "gone" -> ApprovalOutcome.GONE + "failed" -> throw HostException("The computer couldn't write the answer. Try again, or open the session.") + else -> ApprovalOutcome.ALREADY_HANDLED + } + } + + override suspend fun rememberApproval(event: InboxEvent, suggestionIndex: Int): ApprovalOutcome { + val ticket = event.pendingId ?: return ApprovalOutcome.UNSUPPORTED + return answerHook("approvals.answer", buildJsonObject { + put("nodeId", event.nodeId); put("pendingId", ticket) + put("decision", "allow-always"); put("suggestionIndex", suggestionIndex) + }) + } + + override suspend fun answerQuestions(event: InboxEvent, selections: List>): ApprovalOutcome { + val ticket = event.questionPendingId ?: return ApprovalOutcome.UNSUPPORTED + return answerHook("questions.answer", buildJsonObject { + put("nodeId", event.nodeId); put("pendingId", ticket) + put("selections", JsonArray(selections.map { row -> JsonArray(row.map(::JsonPrimitive)) })) + }) + } + + private suspend fun answerHook(verb: String, params: JsonObject): ApprovalOutcome { + val body = try { J.obj(call(verb, params)) } catch (e: HostException) { + if (e.message?.contains("not served") == true || e.message?.startsWith("Unknown method") == true) return ApprovalOutcome.UNSUPPORTED + throw e + } + if (body?.b("answered") == true) return ApprovalOutcome.SENT + return when (body?.s("reason")) { + "gone" -> ApprovalOutcome.GONE + "failed" -> throw HostException("The computer couldn't write the answer. Try again, or open the session.") + else -> ApprovalOutcome.ALREADY_HANDLED + } + } + + override suspend fun ackRead(nodeId: String, eventId: String?) { + try { + call("inbox.ack", buildJsonObject { put("nodeId", nodeId) }) + } catch (_: HostException) { + // An older desktop: the read stays phone-local, exactly as before the verb existed. + } + } + + /** + * Type a quick answer. `node.sendKeys` types through the node's EXISTING session on the desktop + * (audit A12); `sent:false` means it could not be delivered, which is a [HostException] so the + * caller opens the session instead. A desktop that predates the verb gets the old + * attach-and-write, made less lossy: the keys go only after the pane has painted (proof the + * desktop has attached its client), and the stream stays open well past tmux's escape-time so a + * lone ESC is not killed with the client. + */ + override suspend fun sendKeys(nodeId: String, keys: String) { + val body = try { + J.obj(call("node.sendKeys", buildJsonObject { + put("nodeId", nodeId) + put("keys", keys) + })) + } catch (e: HostException) { + if (e.message?.contains("not served") == true || e.message?.startsWith("Unknown method") == true) { + legacySendKeys(nodeId, keys) + return + } + throw e + } + if (body?.b("sent") != true) throw HostException("The answer couldn't be typed into the session. Open it to answer there.") + } + + private suspend fun legacySendKeys(nodeId: String, keys: String) { + val painted = CompletableDeferred() + val sink = object : TerminalSink { + override fun onPaint(text: String) {} + override fun onOutput(bytes: ByteArray) { + painted.complete(Unit) + } + override fun onExit(code: Int?) { + painted.complete(Unit) + } + } + val stream = attach(nodeId, 80, 24, sink) + try { + withTimeoutOrNull(3_000) { painted.await() } + stream.write(keys) + delay(400) + } finally { + stream.detach() + } + } + + /** `git.*` with `{cwd, …args}`; the host's refusal ("git is not served on this host.", "cwd is + * outside the shared project roots.") arrives as a [HostException] carrying that sentence. */ + override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? = + call(verb.wire, JsonObject(args + ("cwd" to JsonPrimitive(cwd))), verb.timeoutMs) + + override fun setOnChanged(listener: (() -> Unit)?) { + onChanged = listener + } + + override fun setOnClosed(listener: ((String?) -> Unit)?) { + onClosed = listener + } + + override fun close() { + streams.clear() + socket.close() + } + + companion object { + /** + * Open the socket and wire this connection as its listener. [onReady] fires with the SAS once + * the E2EE handshake completes; APPROVAL is separate (the desktop human, or a pin) — see + * [RelayConnector]. + */ + fun open(build: (RelaySocketListener) -> RelaySocket, onReady: (String) -> Unit): RelayHostConnection { + val conn = RelayHostConnection() + conn.readyListener = onReady + conn.socket = build(conn) + return conn + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt new file mode 100644 index 0000000000..adb5031dfa --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ResumeOffer.kt @@ -0,0 +1,152 @@ +package dev.nodeterm.protocol.host + +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.Pane +import dev.nodeterm.protocol.model.ProjectsSnapshot + +/** + * The agent line the terminal screen OFFERS right after an attach. It is typed only when the user + * taps it, never unasked into a pane the phone cannot see. There are two cases, and they build + * different lines on purpose: + * + * - [Kind.RESUME] (audit A15): the attach had to CREATE the tmux session, because the computer + * rebooted. The conversation is on disk and the pane is a new shell (in `$HOME` when the relay + * created it), so the line is built like the desktop's cold restore: `cd` into the node's folder, + * the node's managed Claude account, and its agent's approval policy ([Launch.resumeLine]). + * - [Kind.WAKE] (audit A76): the session is Sleeping. Eco (or a shallow "Pause session") exited the + * CLI and left the pane's shell behind. That shell already sits in the node's folder, and its tmux + * env already carries the account's CLAUDE_CONFIG_DIR / CODEX_HOME, so the line is the desktop's + * own wake line: the resume plus its measured approval policy ([Launch.wakeLine]). It is typed after a kill-line + * so a half-typed line left at that prompt is not spliced into it. A `cd` or an account prefix + * could only be wrong there: the pane's shell and env are the authority, not project.json. + * + * A wake is offered only while a SHELL owns the pane ([Pane.isShell] of the pane's foreground + * command, read over SSH right before), the gate the desktop's own wake keeps. The mirror's + * `hibernated` flag alone is not enough, because it can outlive the sleep. A desktop older than the + * A76 review dropped its own copy on a resumed CLI's first live hook event without telling the + * mirror (and codex reports its start only that way), so a CLI started outside the desktop's own + * wake, this phone's included, stayed Sleeping there. With the desktop app not running, nothing + * hears the resumed CLI at all. A running CLI would take the wake line as a prompt. + * + * A wake is offered over DIRECT SSH only. A relay attach tells the desktop that a phone opened the + * node (host-service `remoteViewer.attached` → `agent:wake`), and the desktop wakes it itself, after + * checking that the pane is still the one its CLI exited from. Offering the line on the phone as well + * would type `--resume` into the CLI the desktop just started, where it arrives as a prompt. The + * desktop resolves saved offscreen nodes too, and refuses a missing or changed pane proof. Over SSH nothing tells + * the desktop about the attach, which is why the phone offers it there. + * + * `paused`: the desktop never wakes a paused node on its own, but its own UI offers the explicit + * Resume (the PAUSED chip, the node menu). The mirror carries `hibernated` and not `paused`, so a + * shallow-paused node reads as Sleeping here too; an offer the user taps is that explicit Resume, + * never an automatic one. A deep pause ("pause & end session") recycles the tmux session and leaves + * `hibernated` unset, so it gets no wake offer. + * + * A reattach of the same screen (the stream dropped, the app went to the background) is warm: the + * cold attach before it created the session. An unanswered [Kind.RESUME] is therefore handed back in + * as `carried` and kept, not re-derived from `fresh` (which would drop it: the A41 review). It is kept + * only while the computer still describes the conversation it was made for, with nothing reported + * from the node since ([statusAt]): the phone did not see the pane while it was away, and a CLI + * started there meanwhile would take the resume line as a prompt. A wake needs no carrying; every + * attach re-derives it. + * + * [statusAt] is the node's mirror entry `updatedAt` the offer was built against (null: the computer + * had no entry). The mirror moves it only when a hook event from the node arrives. + */ +data class ResumeOffer(val kind: Kind, val agent: Agent, val command: String, val statusAt: Long?) { + enum class Kind { RESUME, WAKE } + + /** What accepting the offer writes into the pane. */ + val keys: String + get() = when (kind) { + Kind.RESUME -> command + "\r" + // The desktop's wake clears the line first (TerminalNode's wake closure, KILL_LINE in + // src/shared/shell-kill-line.ts). The host is POSIX: a Windows desktop pairs relay-only. + Kind.WAKE -> KILL_LINE + command + "\r" + } + + val message: String + get() = when (kind) { + Kind.RESUME -> "This session had ended on the computer (it restarted). Resume the ${agent.label} conversation?" + Kind.WAKE -> "This session is sleeping: ${agent.label} was closed on the computer to save memory. Wake the conversation?" + } + + val button: String + get() = when (kind) { + Kind.RESUME -> "Resume" + Kind.WAKE -> "Wake ${agent.label}" + } + + /** + * Re-asked when the user taps the offer, against the snapshot at that moment and the pane's + * foreground command read just then ([paneCommand]; the same rule as the Inbox quick actions: + * act only on what is still true). A wake is withdrawn once the node is no longer Sleeping, or + * once anything but a shell owns the pane: a CLI started there meanwhile (the desktop woke it, or + * someone typed the resume) would take the line as a prompt. An unread pane (null) withdraws it. + */ + fun stillOffered(current: ProjectsSnapshot, nodeId: String, paneCommand: String? = null): Boolean = when (kind) { + Kind.RESUME -> true + Kind.WAKE -> current.statusOf(nodeId)?.hibernated == true && Pane.isShell(paneCommand) + } + + companion object { + /** `KILL_LINE` (src/shared/shell-kill-line.ts): Ctrl-U, the POSIX shells' line discard. */ + const val KILL_LINE = "\u0015" + + /** Shown when a tapped wake is withdrawn ([stillOffered]): nothing was typed. */ + const val WITHDRAWN = "This session is no longer sleeping on the computer, so nothing was typed." + + /** + * Whether [afterAttach] could offer a wake for this attach, i.e. whether the caller must read + * the pane's foreground command ([HostConnection.paneCommand]) to pass it in. Only then: the + * read is one more command over SSH. + */ + fun wantsPane(fresh: Boolean, transport: TransportKind, snapshot: ProjectsSnapshot, nodeId: String): Boolean = + !fresh && transport == TransportKind.SSH && snapshot.statusOf(nodeId)?.hibernated == true + + /** + * The offer after an attach of [nodeId] over [transport], or null. [fresh] is the attach's + * own answer ([TerminalStream.fresh]); [snapshot] is the phone's latest view of the computer. + * [carried] is the offer the screen still shows from its previous attach, unanswered: a + * [Kind.RESUME] survives a warm reattach while the computer builds exactly that offer again + * for a cold pane (same line, and no hook event from the node since, [statusAt]); otherwise + * it is dropped. A fresh reattach is a new cold pane and gets the line built now. + * [paneCommand] is the pane's foreground command, read after this attach when [wantsPane] + * said so; a wake needs it to name a shell, and omitting it (null: unread) offers no wake. + */ + fun afterAttach( + fresh: Boolean, + transport: TransportKind, + snapshot: ProjectsSnapshot, + nodeId: String, + carried: ResumeOffer? = null, + paneCommand: String? = null + ): ResumeOffer? { + // The pane is still the cold one the earlier attach created, so the cold line, and it + // wins over a Sleeping flag as it did then. + if (!fresh && carried?.kind == Kind.RESUME) return build(cold = true, transport, snapshot, nodeId, null).takeIf { it == carried } + return build(fresh, transport, snapshot, nodeId, paneCommand) + } + + private fun build(cold: Boolean, transport: TransportKind, snapshot: ProjectsSnapshot, nodeId: String, paneCommand: String?): ResumeOffer? { + val found = snapshot.findNode(nodeId) + val project = found?.first + val node = found?.second + val status = snapshot.statusOf(nodeId) + val agent = Agent.of(node?.agentId ?: status?.agentId) ?: return null + val sid = status?.sessionId ?: node?.agentSessionId ?: return null + val settings = snapshot.status?.settings + val at = status?.updatedAt + return when { + // A cold pane wins over a stale Sleeping flag: the shell the CLI exited to is gone + // with the old tmux session, so the new one needs the full cold-restore line. + cold -> Launch.resumeLine(agent, sid, settings, node?.accountId, project?.absoluteCwdOf(node), project?.defaultPermissionMode) + ?.let { ResumeOffer(Kind.RESUME, agent, it, at) } + // Sleeping by the mirror AND a shell in the pane: the flag can outlive the sleep. + status?.hibernated == true && transport == TransportKind.SSH && Pane.isShell(paneCommand) -> + Launch.wakeLine(agent, sid, settings, project?.defaultPermissionMode)?.let { ResumeOffer(Kind.WAKE, agent, it, at) } + else -> null + } + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalActions.kt new file mode 100644 index 0000000000..202c9886b2 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalActions.kt @@ -0,0 +1,245 @@ +package dev.nodeterm.protocol.host + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlin.coroutines.coroutineContext + +/** + * The input order for one installed viewer. Relay scrolls await an RPC, so launching one coroutine + * per gesture can reorder a reversal or let Esc overtake a scroll that later re-enters copy mode. + * There is one drain, and its lifetime belongs to this stream, never a replacement viewer. + */ +class TerminalActions( + scope: CoroutineScope, + private val stream: TerminalStream, + private val onScrollView: (TerminalScrollView.Result, Long, Int) -> Unit = { _, _, _ -> }, + private val isCurrent: () -> Boolean, +) { + private sealed interface Action { + class Scroll(val up: Boolean, var notches: Int, val epoch: Long = 0, val displayEpoch: Int = 0) : Action + class Input(val data: String) : Action + class Composed(val input: ComposedInput, val result: CompletableDeferred, var dispatched: Boolean = false) : Action + } + + private val lock = Any() + private val pending = ArrayDeque() + private val wake = Channel(Channel.CONFLATED) + private var closed = false + // Include the currently awaited scroll / write in the budgets. + private var scrollNotches = 0 + private var inputChars = 0 + private var inFlightComposed: Action.Composed? = null + private var historyEpoch = 0L + private var scrollBlocked = false + val scrollPaused: Boolean get() = synchronized(lock) { scrollBlocked } + /** Rechecked after a UI runnable reaches the main thread, not only before it is posted. */ + val scrollEpoch: Long get() = synchronized(lock) { historyEpoch } + + private val drain = scope.launch { + try { + for (signal in wake) { + while (true) { + coroutineContext.ensureActive() + val action = synchronized(lock) { + if (closed || !isCurrent()) { + retireLocked() + null + } else when (val head = pending.firstOrNull()) { + is Action.Scroll -> { + // Leave unsent distance at the front: write() can cancel it even + // while this chunk's RPC is suspended. No host receives >20. + val chunk = minOf(head.notches, MAX_SCROLL_CALL) + head.notches -= chunk + if (head.notches == 0) pending.removeFirst() + Action.Scroll(head.up, chunk, historyEpoch, head.displayEpoch) + } + is Action.Input -> pending.removeFirst() + is Action.Composed -> pending.removeFirst().also { inFlightComposed = head } + null -> null + } + } ?: break + coroutineContext.ensureActive() + if (!isCurrent()) { + if (action is Action.Composed) action.result.complete(ComposedInputResult.refused()) + break + } + try { + when (action) { + is Action.Scroll -> { + val result = stream.scrollView(action.up, action.notches) + synchronized(lock) { + if (!closed && isCurrent() && historyEpoch == action.epoch) { + if (result is TerminalScrollView.Result.Refused || result is TerminalScrollView.Result.Uncertain) { + discardScrollLocked(); scrollBlocked = true + } + onScrollView(result, action.epoch, action.displayEpoch) + } + } + } + is Action.Input -> stream.write(action.data) + is Action.Composed -> { + val admitted = synchronized(lock) { + if (closed || !isCurrent() || action.result.isCompleted) false + else { action.dispatched = true; true } + } + if (!admitted) { + action.result.complete(ComposedInputResult.refused()) + } else { + val result = stream.submitComposed(action.input) + action.result.complete(if (isCurrent()) result else ComposedInputResult.uncertain()) + } + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + // A timed-out/refused relay RPC need not disconnect the transport. Do + // not retry uncertain movement, but keep explicit input usable (Esc). + synchronized(lock) { + if (!closed && action is Action.Scroll && isCurrent() && historyEpoch == action.epoch) { + discardScrollLocked() + scrollBlocked = true + onScrollView(TerminalScrollView.Result.Uncertain("Scroll could not be confirmed. Scroll was not repeated."), action.epoch, action.displayEpoch) + } + } + if (action is Action.Composed) action.result.complete( + ComposedInputResult.uncertain()) + } + synchronized(lock) { + if (!closed) when (action) { + is Action.Scroll -> scrollNotches -= action.notches + is Action.Input -> inputChars -= action.data.length + is Action.Composed -> { inputChars -= action.input.size; inFlightComposed = null } + } + } + } + if (synchronized(lock) { closed || !isCurrent() }) break + } + } finally { + synchronized(lock) { retireLocked() } + } + } + + /** False means the caller must report busy rather than silently discard a gesture. */ + fun scroll(up: Boolean, notches: Int, displayEpoch: Int = 0): Boolean = synchronized(lock) { + if (closed || !drain.isActive || !isCurrent()) return false + if (scrollBlocked) return false + if (notches <= 0) return false + if (notches > MAX_SCROLL_NOTCHES - scrollNotches) return false + val tail = pending.lastOrNull() + if (tail is Action.Scroll && tail.up == up && tail.displayEpoch == displayEpoch) { + tail.notches += notches + } else { + if (pending.size >= MAX_PENDING_RUNS) return false + pending.addLast(Action.Scroll(up, notches, displayEpoch = displayEpoch)) + } + scrollNotches += notches + wake.trySend(Unit) + true + } + + /** + * Input is a scroll barrier: discard unsent gestures, preserving already accepted input and + * the single in-flight RPC. Esc is then the next operation after that RPC, with no late scroll + * from this gesture left to put tmux back into copy mode. + */ + fun write(data: String): Boolean = addInput(data, cancelScroll = true) + + /** Automatic emulator replies (DSR/DA etc.) are ordered writes, never gesture barriers. */ + fun report(data: String): Boolean = addInput(data, cancelScroll = false) + + /** Await one captured-view Send, behind the reserved scroll and ahead of any later gesture. */ + suspend fun submit(input: ComposedInput): ComposedInputResult { + if (!input.valid()) return ComposedInputResult.refused("Send is too large or invalid. The draft was kept.") + val result = CompletableDeferred() + val action = Action.Composed(input.normalized(), result) + synchronized(lock) { + if (closed || !drain.isActive || !isCurrent() || + pending.count { it !is Action.Scroll } >= MAX_PENDING_RUNS || input.size > MAX_INPUT_CHARS - inputChars) + return ComposedInputResult.refused("Terminal input is busy or no longer attached. The draft was kept.") + discardScrollLocked() + clearHistoryLocked() + pending.addLast(action) + inputChars += action.input.size + wake.trySend(Unit) + } + try { return result.await() } + catch (cancelled: CancellationException) { + // A cancelled caller may retire an unsent action. Once dispatched its result is + // uncertain; stopping the wait never resubmits it or redirects it to another viewer. + synchronized(lock) { + if (pending.remove(action)) inputChars -= action.input.size + action.result.complete(if (action.dispatched) ComposedInputResult.uncertain() else ComposedInputResult.refused()) + } + throw cancelled + } + } + + /** + * A new touch interrupts old momentum without discarding accepted input or emulator replies. + * The single already reserved/awaited scroll is allowed to finish; later movement is new work. + */ + fun cancelScroll(): Boolean = synchronized(lock) { + if (closed || !drain.isActive || !isCurrent()) return false + discardScrollLocked() + scrollBlocked = false + true + } + + /** Returning to live, resizing, or explicit Send also invalidates already posted pages. */ + fun closeScrollView() = synchronized(lock) { discardScrollLocked(); clearHistoryLocked() } + + private fun addInput(data: String, cancelScroll: Boolean): Boolean = synchronized(lock) { + if (closed || !drain.isActive || !isCurrent()) return false + if (data.isEmpty()) return true + val retainedRuns = if (cancelScroll) pending.count { it !is Action.Scroll } else pending.size + if (retainedRuns >= MAX_PENDING_RUNS || data.length > MAX_INPUT_CHARS - inputChars) return false + if (cancelScroll) { discardScrollLocked(); clearHistoryLocked() } + pending.addLast(Action.Input(data)) + inputChars += data.length + wake.trySend(Unit) + true + } + + /** Retire before leaving, replacing, or destroying the viewer. Idempotent. */ + fun close() { + synchronized(lock) { retireLocked() } + drain.cancel() + } + + private fun retireLocked() { + if (!closed) { clearHistoryLocked(); stream.retireComposed() } + closed = true + inFlightComposed?.let { it.result.complete(if (it.dispatched) ComposedInputResult.uncertain() else ComposedInputResult.refused()) } + inFlightComposed = null + pending.filterIsInstance().forEach { it.result.complete(ComposedInputResult.refused()) } + pending.clear() + scrollNotches = 0 + inputChars = 0 + wake.close() + } + + private fun discardScrollLocked() { + val iterator = pending.iterator() + while (iterator.hasNext()) { + val action = iterator.next() + if (action is Action.Scroll) { + scrollNotches -= action.notches + iterator.remove() + } + } + } + + private fun clearHistoryLocked() { historyEpoch++; scrollBlocked = false; stream.clearScrollView() } + + companion object { + private const val MAX_PENDING_RUNS = 64 + private const val MAX_SCROLL_NOTCHES = 320 + private const val MAX_INPUT_CHARS = 1024 * 1024 + private const val MAX_SCROLL_CALL = 20 + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalExit.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalExit.kt new file mode 100644 index 0000000000..e7c604568f --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalExit.kt @@ -0,0 +1,7 @@ +package dev.nodeterm.protocol.host + +/** An attached viewer's exit alone cannot establish whether its host session is still running. */ +object TerminalExit { + fun closedMessage(code: Int?): String = + if (code == null) "Disconnected." else "The terminal connection closed (exit $code)." +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalHandoff.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalHandoff.kt new file mode 100644 index 0000000000..adc5281b6c --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalHandoff.kt @@ -0,0 +1,177 @@ +package dev.nodeterm.protocol.host + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * A terminal stream with more than one reason to stay attached (audit A40). + * + * The screen showing the stream is one holder (every lease starts with that hold). A session the + * phone is starting is another, from the moment its attach returns until the launch line is typed + * and the node is registered ([PhoneLaunch]). The stream is detached when the LAST holder lets go, + * exactly once, on whichever thread that happens. + */ +class StreamLease(val stream: TerminalStream, private val detach: (TerminalStream) -> Unit) { + private var holds = 1 + + /** Take another hold. False once the stream has been let go of (it is detached, or ended). */ + @Synchronized + fun acquire(): Boolean { + if (holds == 0) return false + holds++ + return true + } + + /** Let go of one hold. The last one detaches the stream; a release after that does nothing. */ + fun release() { + val last = synchronized(this) { + if (holds == 0) return + holds-- + holds == 0 + } + if (last) detach(stream) + } + + /** The stream ended by itself (the pane exited, or the connection went): nothing is left to detach. */ + @Synchronized + fun ended() { + holds = 0 + } +} + +/** + * The screen's side of the attach hand-off (audit A40). + * + * An attach runs off the main thread and hands its stream over afterwards, by a posted runnable. By + * the time that runs, the screen may have gone to the background, gone for good, or started a newer + * attach. So every attach takes a ticket ([begin]); [leave] and [close] invalidate every ticket + * issued so far, and [accept] installs a lease only for the CURRENT ticket. Any other hand-off lets + * go of the screen's hold at once, so a stream nobody shows is never left attached. The same ticket + * tells a superseded attach's output and exit apart from the current one's ([isCurrent], [ended]). + * + * Every method is thread-safe; the lease's detach runs outside the lock. + */ +class ViewerSlot { + private var ticket = 0L + private var closed = false + private var lease: StreamLease? = null + + /** The stream the screen is showing, or null. */ + val stream: TerminalStream? + @Synchronized get() = lease?.stream + + /** A new attach starts. Returns its ticket. A stream still shown is let go of. */ + fun begin(): Long { + val t: Long + val old: StreamLease? + synchronized(this) { + ticket++ + t = ticket + old = lease + lease = null + } + old?.release() + return t + } + + /** True while [t] is the latest attach and the screen has not left since it began. */ + @Synchronized + fun isCurrent(t: Long): Boolean = !closed && t == ticket + + /** + * The attach [t] hands over [lease]. Installed only while [t] is current; otherwise the screen's + * hold is released here, which detaches the stream unless someone else (a launch) still holds it. + */ + fun accept(t: Long, lease: StreamLease): Boolean { + val ok = synchronized(this) { + val current = !closed && t == ticket && this.lease == null + if (current) this.lease = lease + current + } + if (!ok) lease.release() + return ok + } + + /** + * The stream of attach [t] ended by itself. True when that was the screen's current stream: it is + * forgotten (there is nothing to detach) and a hand-off of [t] that has not landed yet will not + * install it. False for a stream the screen had already let go of. + */ + fun ended(t: Long): Boolean { + synchronized(this) { + if (closed || t != ticket) return false + ticket++ + lease?.ended() + lease = null + } + return true + } + + /** The screen went to the background: let go of the stream and of every hand-off still in flight. */ + fun leave() { + val old = synchronized(this) { + ticket++ + lease.also { lease = null } + } + old?.release() + } + + /** The screen is gone for good: [leave], and refuse every hand-off from now on. */ + fun close() { + synchronized(this) { closed = true } + leave() + } +} + +/** + * The second half of starting a session from the phone (audits A33, A14, A40). The attach created the + * tmux session in its project; this types the agent's launch line once the shell has settled (a line + * delivered across the rc-file tty flush comes out mangled), THEN puts the node on the canvas. + * Registering first would let the desktop mount it cold and launch the agent too. + * + * The request was consumed to get here, so nothing can retry it, and the launch finishes whatever the + * screen does meanwhile. Leaving the screen is not cancelling the session the user asked for. The + * other option, aborting and leaving the `nt-` session the attach created, is an unregistered + * shell that no canvas shows. So [start] takes its own hold on the lease BEFORE the caller hands the + * stream to the screen, and runs in the caller's long-lived scope, not in the screen's attach job + * (which the screen cancels when it goes to the background). + */ +object PhoneLaunch { + /** How long the new shell gets to settle before its launch line is typed. */ + const val SETTLE_MS = 900L + + /** [CANNOT_REGISTER]: the connection has no `projects.registerNode` (nothing was asked). */ + enum class Outcome { REGISTERED, REFUSED, CANNOT_REGISTER } + + /** + * Start the launch on [lease]'s stream. Call it before the lease is handed to anyone else: it + * throws if the stream has already been let go of. [then] runs after the hold is released. + */ + fun start( + scope: CoroutineScope, + lease: StreamLease, + command: String?, + register: (suspend () -> Boolean)?, + settle: suspend (Long) -> Unit = { delay(it) }, + then: suspend (Outcome) -> Unit = {} + ): Job { + check(lease.acquire()) { "The stream was let go of before its launch started." } + return scope.launch { + val outcome = try { + settle(SETTLE_MS) + if (command != null) runCatching { lease.stream.write(command + "\r") } + when { + register == null -> Outcome.CANNOT_REGISTER + // A refusal is an ANSWER (host-service: the session stays open, just unregistered). + runCatching { register() }.getOrDefault(false) -> Outcome.REGISTERED + else -> Outcome.REFUSED + } + } finally { + lease.release() + } + then(outcome) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalOutput.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalOutput.kt new file mode 100644 index 0000000000..81a6e1e398 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalOutput.kt @@ -0,0 +1,112 @@ +package dev.nodeterm.protocol.host + +import java.io.ByteArrayOutputStream + +/** + * Binary output for one viewer, batched onto its page. [post] must enqueue, never run inline; + * its callbacks and [finish] run on the page's thread. Admission and callback ownership share + * the same lock, including the external viewer check, so a retired reader cannot refill a buffer. + * Each scheduled flush is one-use: even another batch of the same viewer is a different owner. + */ +class TerminalOutput( + private val isCurrent: (Long) -> Boolean, + private val post: (Long, () -> Unit) -> Unit, + private val onPaint: (Long, String) -> Unit, + private val onOutput: (Long, ByteArray) -> Unit +) { + private class Paint(val text: String) + private class Flush(var deferred: Boolean = false) + private class Viewer(val ticket: Long) { + val bytes = ByteArrayOutputStream() + var paint: Paint? = null + var flush: Flush? = null + } + private var viewer: Viewer? = null + + val owner: Long? + @Synchronized get() = viewer?.ticket + + @Synchronized + fun begin(ticket: Long) { viewer = Viewer(ticket) } + + @Synchronized + fun retire() { viewer = null } + + private fun current(v: Viewer): Boolean = viewer === v && isCurrent(v.ticket) + + /** A snapshot replaces only earlier bytes. Later callbacks remain behind its paint barrier. */ + @Synchronized + fun paint(ticket: Long, text: String): Boolean { + val v = viewer ?: return false + if (v.ticket != ticket || !current(v)) return false + v.bytes.reset() + v.flush = null + val pendingPaint = Paint(text) + v.paint = pendingPaint + post(0) { synchronized(this) { + if (!current(v) || v.paint !== pendingPaint) return@synchronized + paint(v) + v.flush?.takeIf { it.deferred }?.let { flush -> + flush.deferred = false + schedule(v, flush) + } + } } + return true + } + + @Synchronized + fun append(ticket: Long, bytes: ByteArray): Boolean { + val v = viewer ?: return false + if (v.ticket != ticket || !current(v)) return false + if (bytes.isEmpty()) return true + v.bytes.write(bytes) + if (v.flush == null) { + val flush = Flush() + v.flush = flush + schedule(v, flush) + } + return true + } + + private fun schedule(v: Viewer, flush: Flush) { + post(BATCH_MS) { synchronized(this) { + if (!current(v) || v.flush !== flush) return@synchronized + // A main-thread backlog may run a delayed flush before its newly posted snapshot. + if (v.paint != null) { flush.deferred = true; return@synchronized } + drain(v) + } } + } + + private fun paint(v: Viewer) { + val paint = v.paint ?: return + v.paint = null + onPaint(v.ticket, paint.text) + } + + private fun drain(v: Viewer) { + v.flush = null + val bytes = v.bytes.toByteArray() + v.bytes.reset() + var offset = 0 + while (offset < bytes.size) { + val end = offset + minOf(CHUNK_BYTES, bytes.size - offset) + onOutput(v.ticket, bytes.copyOfRange(offset, end)) + offset = end + } + } + + /** Keep a current ready page's final output before its ordered exit retires the viewer. */ + @Synchronized + fun finish(ticket: Long): Boolean { + val v = viewer ?: return false + if (v.ticket != ticket || !current(v)) return false + paint(v) + drain(v) + return true + } + + companion object { + const val BATCH_MS = 16L + const val CHUNK_BYTES = 192 * 1024 + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt new file mode 100644 index 0000000000..85669b4962 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalPage.kt @@ -0,0 +1,194 @@ +package dev.nodeterm.protocol.host + +/** + * The terminal screen's page (the WebView running xterm.js), by generation (audit A45). + * + * The renderer process behind a WebView can go away on its own: Android kills it to reclaim memory, + * or it crashes. That WebView can never be used again, so the screen destroys it and builds a new + * one, which loads a new page. Every page gets a generation from [build], and its bridge callbacks + * carry it. A callback of a page that is gone (posted to the main thread before the loss, run after + * it) therefore cannot mark the NEXT page ready ([ready] answers null), and the JavaScript queued for + * a page that never became ready is dropped with it ([lost]) instead of being replayed into the next. + * + * JavaScript for the page goes through [offer]: run it now when the current page is ready, otherwise + * it waits until that page is. What is offered while there is no page at all (after [lost], before + * the next [build]) is kept for the next page: a reattach may paint before the replacement exists. + * + * Thread-safe. The screen calls it on the main thread; [isCurrent] also from the bridge thread. + */ +class TerminalPage { + private var generation = 0 + private var ready = false + private var replacing = false + private data class Offered(val code: String, val viewer: Long?) + private val pending = ArrayList() + private var viewer: Long? = null + val currentGeneration: Int + @Synchronized get() = generation + + /** The current page has loaded and runs JavaScript as it is offered. */ + val isReady: Boolean + @Synchronized get() = ready + + /** + * A page was lost and its replacement has not loaded yet. An attach started now would size the + * pty from defaults (80×24) instead of from the new page, so the screen waits for [ready]. + */ + val isReplacing: Boolean + @Synchronized get() = replacing + + /** A new page starts loading (a new WebView). Returns its generation, for its bridge. */ + @Synchronized + fun build(): Int { + generation++ + ready = false + return generation + } + + /** True while [gen] is the page being shown (or loading): its callbacks count. */ + @Synchronized + fun isCurrent(gen: Int): Boolean = gen == generation + + /** + * Page [gen] finished loading. Returns the JavaScript queued for it, in order, to run now (empty + * when nothing was queued, or when it had already said so), or null when [gen] is a page that is + * gone: it is not ready, and nothing is handed over. + */ + @Synchronized + fun ready(gen: Int): List? { + if (gen != generation) return null + if (ready) return emptyList() + ready = true + replacing = false + return pending.filter { it.viewer == null || it.viewer == viewer }.map { it.code }.also { pending.clear() } + } + + /** JavaScript for the page: true = run it now; false = queued until the page is ready. */ + @Synchronized + fun offer(code: String): Boolean { + if (ready) return true + pending += Offered(code, null) + return false + } + + /** Same-page reconnects retire viewer JS too, while keeping page-global font/config setup. */ + @Synchronized + fun viewerChanged(ticket: Long?) { + viewer = ticket + pending.removeAll { it.viewer != null } + } + + /** Paint, output and input must never be adopted by a later viewer of the same unready page. */ + @Synchronized + fun offerViewer(ticket: Long, code: String): Boolean { + if (viewer != ticket) return false + if (ready) return true + pending += Offered(code, ticket) + return false + } + + /** + * The current page's renderer is gone. Its generation is retired (its late callbacks are + * refused), and what was queued for it is dropped: it was meant for a screen that no longer + * exists, and the reattach that follows paints the whole screen again. + */ + @Synchronized + fun lost() { + generation++ + ready = false + replacing = true + viewer = null + pending.clear() + } +} + +/** + * What the terminal screen does once its page's renderer is gone (audit A45). The page is replaced + * either way; the question is what the screen shows meanwhile, and who reattaches the terminal. The + * screen builds the replacement at once only for an automatic reattach of a visible screen, which + * the bound below limits; otherwise it waits for whatever asks for the next attach (a button, or the + * screen being started again), so a page that dies as it loads is not rebuilt with nobody asking. + * + * It depends first on what the screen was [Showing]: + * - An ANSWER with its own button ([Showing.SETTLED]: the session ended or the connection dropped, + * the session opens through the relay, or the view was already lost) is kept as it was, and its + * button drives the next attach. Nothing reattaches unasked: over the relay an attach to a pane that + * has exited creates a new, empty session, and after a crash offer an automatic reattach would repaint + * the screen the user was asked about. + * - Otherwise ([Showing.ATTACHED] or [Showing.OPENING]) the attach the loss retired is replaced: + * - the system KILLED the renderer (`didCrash` false): nothing is wrong with what the page showed, + * so the screen reattaches by itself once the new page is ready. With the renderer's priority + * waived while the screen is not visible, this is the normal way a backgrounded terminal loses its + * page; + * - the renderer CRASHED: the cause may be what the pane printed, and a reattach paints the same + * screen again, so reattaching unasked could crash it over and over. The screen offers "Reopen + * terminal" and the user decides; + * - a kill also falls back to the offer after [maxAutomatic] automatic reattaches within [windowMs]: + * a device that keeps killing a visible renderer should not have the app fight it in a loop. A + * kill while the screen is not visible does not count (the review of A45): the renderer's priority + * is waived then, so that kill is expected, and the screen reattaches only when it comes back. + * + * The offer says the session is still running only when a stream was attached ([Showing.ATTACHED]): + * that is the only case in which the screen knew. While it was still opening the terminal, it knew + * nothing about the session yet (it may not exist, or not on this host). + * + * Thread-safe. [onGone]'s `now` is a MONOTONIC millisecond clock supplied by the caller + * (`SystemClock.elapsedRealtime()` on the phone): with a wall clock, one set back would keep old + * kills in the window, and one set forward would clear them. + */ +class RendererRecovery(private val windowMs: Long = 60_000L, private val maxAutomatic: Int = 2) { + /** What the terminal screen was showing when its renderer went away. */ + enum class Showing { + /** A stream was attached: the session was running a moment ago. */ + ATTACHED, + + /** + * Opening the terminal: connecting, waiting for the computer to approve this phone, waiting + * for a page, or in the background. The loss retires the attach in flight. + */ + OPENING, + + /** An answer with its own button (ended, disconnected, relay offer, view lost). Nothing in flight. */ + SETTLED + } + + sealed interface Outcome { + /** Keep what the screen shows; its own button drives the next attach. */ + data object Keep : Outcome + + /** Reattach by itself once the new page is ready. */ + data object Reattach : Outcome + + /** Offer "Reopen terminal" with [message]; the user decides when to reattach. */ + data class Offer(val message: String) : Outcome + } + + private val automatic = ArrayDeque() + + /** + * The renderer went away: [didCrash] as Android reports it, [now] on a monotonic clock, what the + * screen was [showing], and whether the screen was [visible] (started, in front of the user). + */ + @Synchronized + fun onGone(didCrash: Boolean, now: Long, showing: Showing, visible: Boolean): Outcome { + // Not counted against the automatic reattaches: none happens. + if (showing == Showing.SETTLED) return Outcome.Keep + if (!didCrash) { + // Expected while the screen is not visible, and not counted: the bound is for a device + // that keeps killing a renderer the user is looking at. The screen reattaches when it is + // started again, and a kill after that counts. + if (!visible) return Outcome.Reattach + while (automatic.isNotEmpty() && now - automatic.first() >= windowMs) automatic.removeFirst() + if (automatic.size < maxAutomatic) { + automatic.addLast(now) + return Outcome.Reattach + } + } + return Outcome.Offer(offerMessage(didCrash, showing)) + } + + private fun offerMessage(didCrash: Boolean, showing: Showing): String { + val what = if (didCrash) "The terminal view crashed." else "Android keeps closing the terminal view to free memory." + return if (showing == Showing.ATTACHED) "$what The session is still running on the computer." else what + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalScrollView.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalScrollView.kt new file mode 100644 index 0000000000..7070ffc336 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalScrollView.kt @@ -0,0 +1,66 @@ +package dev.nodeterm.protocol.host + +import kotlinx.serialization.json.* + +/** Inert physical rows from one immutable, viewer-owned host capture, never terminal bytes. */ +object TerminalScrollView { + const val MAX_ROWS = 200 + const val MAX_BYTES = 256 * 1024 + const val MAX_TOTAL_ROWS = 8192 + private val uuid = Regex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + data class Row(val text: String, val isWrapped: Boolean, val section: String) + sealed interface Result { + data class History(val viewId: String, val offset: Int, val totalRows: Int, val cols: Int, + val rows: List, val olderTruncated: Boolean, val hasOlder: Boolean, val hasNewer: Boolean) : Result { + fun json(): String = buildJsonObject { + put("status", "history"); put("viewId", viewId); put("offset", offset) + put("totalRows", totalRows); put("cols", cols); put("olderTruncated", olderTruncated) + put("hasOlder", hasOlder); put("hasNewer", hasNewer) + put("rows", buildJsonArray { rows.forEach { row -> add(buildJsonObject { + put("text", row.text); put("isWrapped", row.isWrapped); put("section", row.section) + }) } }) + }.toString() + } + data object Input : Result + data class Refused(val message: String) : Result + data class Uncertain(val message: String) : Result + } + private fun JsonObject.string(key: String): String? = (get(key) as? JsonPrimitive)?.takeIf { it.isString }?.content + private fun JsonObject.number(key: String): Int? = (get(key) as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull + private fun JsonObject.bool(key: String): Boolean? = (get(key) as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull + fun parse(value: JsonElement?): Result? { + val o = value as? JsonObject ?: return null + if (o.toString().toByteArray(Charsets.UTF_8).size > MAX_BYTES) return null + return when (o.string("status")) { + "input" -> Result.Input + "refused", "uncertain" -> { + val message = o.string("message")?.takeIf { it.isNotBlank() && it.length <= 1024 } ?: return null + if (o.string("status") == "refused") Result.Refused(message) else Result.Uncertain(message) + } + "history" -> { + val id = o.string("viewId")?.takeIf { uuid.matches(it) } ?: return null + val offset = o.number("offset") ?: return null + val total = o.number("totalRows") ?: return null + val cols = o.number("cols") ?: return null + val older = o.bool("hasOlder") ?: return null + val newer = o.bool("hasNewer") ?: return null + val truncated = o.bool("olderTruncated") ?: return null + val raw = o["rows"] as? JsonArray ?: return null + if (total !in 1..MAX_TOTAL_ROWS || cols !in 1..65535 || raw.isEmpty() || raw.size > MAX_ROWS || + offset < 0 || offset > total - raw.size || older != (offset < total - raw.size) || newer != (offset > 0)) return null + val rows = raw.map { element -> + val row = element as? JsonObject ?: return null + val text = row.string("text") ?: return null + // Host emulator rows are printable text. Refuse escape/control bytes even + // though the WebView's separate layer would render them through textContent. + if (text.any { it.code < 32 || it.code in 127..159 }) return null + val wrapped = row.bool("isWrapped") ?: return null + val section = row.string("section")?.takeIf { it == "normal" || it == "alternate" } ?: return null + Row(text, wrapped, section) + } + Result.History(id, offset, total, cols, rows, truncated, older, newer) + } + else -> null + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AccountNames.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AccountNames.kt new file mode 100644 index 0000000000..c57b4c988a --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AccountNames.kt @@ -0,0 +1,87 @@ +package dev.nodeterm.protocol.model + +/** + * Display names for Claude accounts, so no screen prints a managed account's raw UUID (audit A39, + * A75). One resolver for every surface that names an account — the New session picker and the + * sessions row — so the two cannot disagree about the same id. + * + * Where a name comes from, in order: + * 1. `settings.claudeAccounts[].label` — written by both shells (desktop and Server Edition) and + * the desktop's per-host SSH slice (`mirrorClaudeAccount`), so it is the one source that is + * there whenever the account is; + * 2. `usage.accounts[].label` — a host older than (1) still has it, but only for local accounts + * (it is dropped from SSH slices) and only after the first usage poll; + * 3. the email, from either block; + * 4. a short form of the id ([fallbackName]) — never the whole UUID. + * + * The mirror is a file the desktop documents as hand-editable, so a blank value counts as absent. + */ +object AccountNames { + /** The name the picker gives the system `~/.claude` account (it has no record to name). */ + const val SYSTEM = "System account" + + /** How much of an unnamed account's id is shown. A managed id is a random UUID: eight hex + * characters tell two accounts apart and do not fill a line. */ + private const val SHORT_ID = 8 + + /** The display name of the managed (or linked) Claude account [accountId] on the host whose + * mirror is [status]. */ + fun managed(accountId: String, status: AgentStatusFile?): String = named(accountId, status) ?: fallbackName(accountId) + + /** Steps 1–3 above: a name the mirror gives [accountId], or null when it gives none. */ + private fun named(accountId: String, status: AgentStatusFile?): String? { + val settings = status?.settings?.claudeAccounts?.firstOrNull { it.id == accountId } + val usage = status?.usage?.accounts?.firstOrNull { it.accountId == accountId } + return settings?.label.nonBlank() + ?: usage?.label.nonBlank() + ?: settings?.email.nonBlank() + ?: usage?.email.nonBlank() + } + + /** + * What a session row says about the account the session was OBSERVED running as, or null for + * nothing. The desktop's account chip decides the same three cases (renderer/lib/accountChip.ts): + * - an account id → that account's name. An id this mirror cannot name (removed since the + * observation, or pinned to another machine) falls back to its dir, as the desktop's + * `resolveObserved` does — unless the dir's last segment is only the id again, which is what + * a managed account's dir is; then the short id; + * - no id and not known → a config dir the desktop has no record of, named by its last path + * segment (`.claude-2`), the only name such a dir has; + * - no id and known → the system account, the unremarkable case: nothing. + */ + fun observed(account: ObservedAccount?, status: AgentStatusFile?): String? { + if (account == null) return null + val dir = account.configDir?.let(::configDirLabel)?.nonBlank() + account.accountId?.nonBlank()?.let { id -> + return named(id, status) ?: dir?.takeIf { it != id } ?: fallbackName(id) + } + if (account.known) return null + return dir + } + + /** An account nothing names: a short, recognisable form of its id. */ + fun fallbackName(accountId: String): String { + val id = accountId.trim() + return "Account " + if (id.length > SHORT_ID) id.take(SHORT_ID) else id + } + + /** + * The last segment of a config dir (`configDirLabel`, renderer/lib/accountChip.ts). The dir may + * come from this desktop, an SSH host or Windows, so the separator follows the SHAPE of the + * string: a drive letter, a UNC prefix, or backslashes with no forward slash is Windows-shaped, + * and there both separators split (Windows takes either); anything else is POSIX, where a + * backslash is ordinary filename text and only `/` splits. + */ + fun configDirLabel(configDir: String): String { + val dir = configDir.trim() + if (dir.isEmpty()) return "" + val windows = Regex("^[a-zA-Z]:[\\\\/]").containsMatchIn(dir) || + dir.startsWith("\\\\") || + ('\\' in dir && '/' !in dir) + val parts = (if (windows) dir.split('\\', '/') else dir.split('/')).filter { it.isNotEmpty() } + // A bare root (`/`) has no segment to name; say the whole string rather than nothing. + return parts.lastOrNull() ?: dir + } + + private fun String?.nonBlank(): String? = this?.trim()?.takeIf { it.isNotEmpty() } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt new file mode 100644 index 0000000000..bdbbf4cd9b --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt @@ -0,0 +1,167 @@ +package dev.nodeterm.protocol.model + +import java.security.SecureRandom + +/** + * The builtin agents, as `AGENT_CONFIG` (src/shared/agents/config.ts) defines them — label, brand + * colour, launch command — and the two command lines the phone types: a first launch and a resume. + * The phone never invents a flag the desktop would not emit; where it cannot know a host fact it + * emits the bare command (the desktop's own degrade direction). + */ +enum class Agent(val id: String, val label: String, val color: String, val launchCmd: String, val resumable: Boolean) { + CLAUDE("claude", "Claude Code", "#d97757", "claude", true), + CODEX("codex", "Codex", "#10a37f", "codex", true), + ANTIGRAVITY("antigravity", "Antigravity", "#00a3a3", "agy", true), + GEMINI("gemini", "Gemini", "#4285f4", "gemini", true), + OPENCODE("opencode", "opencode", "#a78bfa", "opencode", true), + GROK("grok", "Grok", "#64748b", "grok", true), + COPILOT("copilot", "GitHub Copilot", "#8957e5", "copilot", true); + + companion object { + fun of(id: String?): Agent? = entries.firstOrNull { it.id == id } + } +} + +object Launch { + /** `SAFE_SESSION_ID` (config.ts): only this charset ever reaches a command line. */ + private val SAFE_SESSION_ID = Regex("^[A-Za-z0-9][A-Za-z0-9._-]*$") + private val PERMISSION_MODES = setOf("manual", "auto", "acceptEdits", "plan", "bypassPermissions") + private val SAFE_DIR = Regex("^/[^\\u0000-\\u001f'\"`$\\\\]*$") + + /** `resumeCommandWith` (config.ts): each builtin's own resume grammar, or null. */ + fun resumeCommand(agent: Agent, sessionId: String): String? { + val sid = sessionId.trim() + if (!agent.resumable || sid.isEmpty() || !SAFE_SESSION_ID.matches(sid)) return null + return when (agent) { + Agent.CODEX -> "${agent.launchCmd} resume $sid" + Agent.OPENCODE -> "${agent.launchCmd} --session $sid" + Agent.COPILOT -> "${agent.launchCmd} --resume=$sid" + Agent.ANTIGRAVITY -> "${agent.launchCmd} --conversation=$sid" + Agent.CLAUDE, Agent.GEMINI, Agent.GROK -> "${agent.launchCmd} --resume $sid" + } + } + + /** + * The permission mode an agent session starts in on this host: the project's own override when + * it names a valid mode, else the global setting — the desktop's `resolvePermissionMode(project, + * settings)` (audit A16). Both values come from hand-editable files, so both are re-validated. + * No mirror settings at all = `manual` (no flag): the phone never invents one. + */ + fun permissionMode(settings: MirrorSettings?, projectMode: String? = null): String = + projectMode?.takeIf { it in PERMISSION_MODES } + ?: settings?.claudePermissionMode?.takeIf { it in PERMISSION_MODES } + ?: "manual" + + /** + * The managed Claude account a new session in [project] starts under by default: the project's + * default, but only when this host still has that account (a stale id falls back to System, + * exactly as the desktop validates an id before stamping it on a node). + */ + fun defaultAccount(settings: MirrorSettings?, project: ProjectInfo?): String? { + val id = project?.defaultAccountId ?: return null + return id.takeIf { settings?.claudeAccounts?.any { it.id == id } == true } + } + + /** + * A first launch using each agent's measured approval dialect. Claude's `auto` flag requires + * this host's own capability; Codex's version-dependent values require its advertised + * vocabulary. Unsupported modes keep the CLI's default rather than substituting a looser one. + */ + fun launchCommand( + agent: Agent, + settings: MirrorSettings?, + accountId: String?, + cwd: String?, + projectMode: String? = null + ): String = compose(agent, agent.launchCmd, settings, accountId, cwd, projectMode) + + /** + * The resume line for a cold attach, built like the desktop's cold restore (audit A15): in the + * node's own directory, under the node's managed Claude account, with the same approval policy as + * [launchCommand]. A + * bare `claude --resume ` typed into a relay-created pane in `$HOME`, under the default + * config dir, finds no transcript for a managed account (and, by the transcript path's encoded + * cwd, likely none at all). Null when the agent or id cannot be resumed. + */ + fun resumeLine( + agent: Agent, + sessionId: String, + settings: MirrorSettings?, + accountId: String?, + cwd: String?, + projectMode: String? = null + ): String? = resumeCommand(agent, sessionId)?.let { compose(agent, it, settings, accountId, cwd, projectMode) } + + /** + * The line that wakes a Sleeping (Eco-hibernated) session, after the desktop's own wake + * (TerminalNode's wake closure: the resume with the approval policy re-resolved, audit A76). + * It carries no `cd` and no account: the pane's shell is the one the CLI exited back to, + * so it already sits in the node's directory and its tmux env already names the account's config + * dir. Null when the agent or id cannot be resumed. + */ + fun wakeLine( + agent: Agent, + sessionId: String, + settings: MirrorSettings?, + projectMode: String? = null + ): String? = resumeCommand(agent, sessionId)?.let { compose(agent, it, settings, null, null, projectMode) } + + private fun compose( + agent: Agent, + command: String, + settings: MirrorSettings?, + accountId: String?, + cwd: String?, + projectMode: String? + ): String { + val parts = ArrayList() + if (!cwd.isNullOrBlank() && SAFE_DIR.matches(cwd)) parts += "cd '${cwd}' &&" + val account = accountId?.let { id -> settings?.claudeAccounts?.firstOrNull { it.id == id } } + if (agent == Agent.CLAUDE && account != null && SAFE_DIR.matches(account.dir)) { + parts += "CLAUDE_CONFIG_DIR='${account.dir}'" + } + parts += command + approvalFlags(agent, permissionMode(settings, projectMode), settings) + .takeIf { it.isNotEmpty() }?.let { parts += it.joinToString(" ") } + return parts.joinToString(" ") + } + + /** The desktop's measured approval dialects; mirror values only select known literal flags. */ + internal fun approvalFlags(agent: Agent, mode: String, settings: MirrorSettings?): List { + if (mode !in PERMISSION_MODES) return emptyList() + return when (agent) { + Agent.CLAUDE, Agent.GROK -> when { + mode == "manual" -> emptyList() + agent == Agent.CLAUDE && mode == "auto" && settings?.autoSupported != true -> emptyList() + else -> listOf("--permission-mode", mode) + } + Agent.GEMINI -> when (mode) { + "plan" -> listOf("--approval-mode", "plan") + "acceptEdits" -> listOf("--approval-mode", "auto_edit") + "bypassPermissions" -> listOf("--approval-mode", "yolo") + else -> emptyList() + } + Agent.CODEX -> { + val wanted = when (mode) { + "manual" -> "untrusted" + "auto" -> "on-request" + "bypassPermissions" -> "never" + else -> return emptyList() + } + // Unknown hosts use the desktop's measured stable baseline; manual's removed + // `untrusted` value is emitted only when this host actually advertised it. + val vocabulary = settings?.codexApprovalValues?.takeIf { it.isNotEmpty() } + ?: listOf("on-request", "never") + if (wanted in vocabulary) listOf("--ask-for-approval", wanted) else emptyList() + } + Agent.OPENCODE, Agent.COPILOT, Agent.ANTIGRAVITY -> emptyList() + } + } + + /** A node id in the desktop's shape (`term--`, project-node-append.ts + * `SAFE_NODE_ID`), so the host registrar accepts it and it is a boring tmux name. */ + fun newNodeId(now: Long = System.currentTimeMillis(), random: SecureRandom = SecureRandom()): String { + val token = ByteArray(5).also(random::nextBytes).joinToString("") { "%02x".format(it) } + return "term-${now.toString(36)}-$token" + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AllComputers.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AllComputers.kt new file mode 100644 index 0000000000..7bce81bee4 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/AllComputers.kt @@ -0,0 +1,130 @@ +package dev.nodeterm.protocol.model + +/** + * One paired computer as a merged screen names it (audit A55): its pairing id, and the label its + * cards and usage sections carry. Built by [AllComputers.labels]. + */ +data class ComputerLabel(val hostId: String, val label: String) + +/** A computer's latest listing (its session's cached snapshot), with the label it is shown under. */ +data class ComputerListing(val computer: ComputerLabel, val snapshot: ProjectsSnapshot) + +/** + * What an Inbox shows (docs/mobile-usage-inbox.md, "Agents tab (feed)"): the open approvals and + * questions newest first, then the sessions working right now, then the archive (finished turns and + * resolved cards), newest first. + * + * One computer's (its Inbox tab) and every paired computer's merged (the "All computers" screen, + * audit A55) are built by the same [of], so the merged feed never sorts, splits or counts differently + * from a computer's own. Every card keeps the computer it came from ([Item.from]): its title, its + * context ring and its answer all belong to that computer's listing and session, never to another's. + */ +data class InboxFeed( + val actionable: List, + val working: List, + val archived: List +) { + /** One event, with the listing it came from. */ + data class Item(val from: ComputerListing, val event: InboxEvent) { + val hostId: String get() = from.computer.hostId + + /** A list key. Two computers can mint the same event id, so it names the computer too. */ + val key: String get() = "$hostId/${event.id}" + + /** The node's context-window fill on its own computer, when known. */ + val contextPercent: Double? get() = from.snapshot.status?.inbox?.nodes?.get(event.nodeId)?.contextPercent + } + + /** A session working right now that reports what it is doing, from the listing it came from. */ + data class Live(val from: ComputerListing, val nodeId: String, val now: InboxNodeNow) { + val hostId: String get() = from.computer.hostId + + /** A list key. Two computers can have the same node id (a canvas committed to two repos). */ + val key: String get() = "$hostId/$nodeId" + } + + val isEmpty: Boolean get() = actionable.isEmpty() && working.isEmpty() && archived.isEmpty() + + companion object { + /** + * The feed of [listings], in their order. Events are sorted newest first ACROSS computers; the + * sort is stable, so events with the same timestamp keep the order of [listings], then each + * mirror's own order. Working sessions are not sorted by time: their `updatedAt` moves with + * every tool call, so a time order would reshuffle the live cards on every refresh. They come + * in [listings] order, each computer's in its mirror's order, which is what a single + * computer's Inbox always showed. + */ + fun of(listings: List): InboxFeed { + val events = listings + .flatMap { l -> l.snapshot.status?.inbox?.events.orEmpty().map { Item(l, it) } } + .sortedByDescending { it.event.ts } + val working = listings.flatMap { l -> + val status = l.snapshot.status ?: return@flatMap emptyList() + status.nodes.mapNotNull { (nodeId, st) -> + val now = status.inbox?.nodes?.get(nodeId) + if (st.state == AgentState.WORKING && now?.activity != null) Live(l, nodeId, now) else null + } + } + return InboxFeed( + actionable = events.filter { it.event.actionable }, + working = working, + archived = events.filterNot { it.event.actionable } + ) + } + } +} + +/** + * The phone's "All computers" screen (audit A55): iOS's Agents and Usages tabs merge every paired + * connection (docs/mobile-usage-inbox.md "iOS behavior (v1)"), where the app had only one computer's + * Inbox and Usage, inside that computer's screen. The rules are here, pure; the screen only draws. + */ +object AllComputers { + /** + * What a computer needs from you: its open approvals and questions. One definition for the + * count on its row in the computers list, on its Inbox tab, and on the All computers screen. + */ + fun needsYou(snapshot: ProjectsSnapshot): Int = snapshot.status?.inbox?.events?.count { it.actionable } ?: 0 + + /** + * Whether the computers list offers the merged screen: once a second computer is paired. With one, + * that computer's own Inbox and Usage tabs already are all of it. + */ + fun offered(pairedCount: Int): Boolean = pairedCount > 1 + + /** + * What each computer is called on a merged screen, in [hosts] order. Its pairing name, or + * "Computer" for a blank one. Two computers paired under the same name (two machines both called + * "MacBook Pro") would be told apart by nothing, so a shared name gets the address the phone + * dials (`user@host`), and a label that is still shared (the same computer paired twice) a number + * in pairing order. Names compare case-insensitively, the way a reader does. + */ + fun labels(hosts: List): List { + val names = hosts.map { it.name.trim().ifEmpty { "Computer" } } + val withAddress = hosts.mapIndexed { i, h -> + if (names.count { it.equals(names[i], ignoreCase = true) } > 1) "${names[i]} (${h.user}@${h.host})" else names[i] + } + return hosts.mapIndexed { i, h -> + val same = withAddress.indices.filter { withAddress[it].equals(withAddress[i], ignoreCase = true) } + ComputerLabel(h.id, if (same.size > 1) "${withAddress[i]} #${same.indexOf(i) + 1}" else withAddress[i]) + } + } + + /** Every computer's Inbox, merged ([InboxFeed.of]): newest first, each card with its computer. */ + fun feed(listings: List): InboxFeed = InboxFeed.of(listings) + + /** One computer's usage on the merged Usage tab: its accounts, under its label and `updatedAt`. */ + data class UsageSection(val computer: ComputerLabel, val usage: MirrorUsage, val status: AgentStatusFile) + + /** + * One section per computer that reports usage (docs/mobile-usage-inbox.md: "one section per paired + * connection that reports `usage`"), in [listings] order. A computer reports nothing when its + * mirror has no `usage` block or no account in it: a computer not listed yet, an older desktop, + * or one whose usage service has nothing cached. It gets no empty section. + */ + fun usage(listings: List): List = listings.mapNotNull { l -> + val status = l.snapshot.status ?: return@mapNotNull null + val usage = status.usage?.takeIf { it.accounts.isNotEmpty() } ?: return@mapNotNull null + UsageSection(l.computer, usage, status) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/BackStack.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/BackStack.kt new file mode 100644 index 0000000000..d09729c454 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/BackStack.kt @@ -0,0 +1,141 @@ +package dev.nodeterm.protocol.model + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.util.UUID + +/** + * The app's back stack, as a value (audit A43). The app's Navigator holds one of these and is only + * type-checked; this pins the rules it delegates to. + * + * Each entry carries a [Entry.key] that belongs to that ENTRY, not to its destination: the same + * screen can sit on the stack twice, and the app files each entry's saved UI state (the selected tab, + * scroll positions, the Board's project) under its key. Only the top entry is composed, so a screen + * below a terminal keeps its state only because its key outlives its composition. + * + * A key whose entry left the stack is RETIRED until the app has dropped the state filed under it + * ([withoutRetired]); otherwise that state would be kept, and saved with the activity, for the rest of + * the process. Retired keys are saved and restored with the stack, so an entry that left just before + * the process died is still cleaned up after it comes back. + * + * The stack is never empty: [pop] refuses the root, and every operation that could empty it takes a + * fallback. + */ +class BackStack private constructor(val entries: List>, val retired: List) { + data class Entry(val key: String, val value: T) + + val current: T get() = entries.last().value + val currentKey: String get() = entries.last().key + val size: Int get() = entries.size + val values: List get() = entries.map { it.value } + + /** [value] on top, under a key no entry on this stack (or waiting to be dropped) has. */ + fun push(value: T, newKey: () -> String = ::randomKey): BackStack = + BackStack(entries + Entry(unusedKey(taken(), newKey), value), retired) + + /** Null when only the root is left: the root is never popped. */ + fun pop(): BackStack? { + if (entries.size <= 1) return null + return BackStack(entries.dropLast(1), retired + entries.last().key) + } + + /** [value] alone. Every entry that was on the stack leaves it, even one showing the same screen. */ + fun replaceAll(value: T, newKey: () -> String = ::randomKey): BackStack = + BackStack(listOf(Entry(unusedKey(taken(), newKey), value)), retired + entries.map { it.key }) + + /** + * The entries [keep] accepts, each under the key it had, so its saved state survives; [fallback] + * (under a new key) when none is left. + */ + fun retain(fallback: T, newKey: () -> String = ::randomKey, keep: (T) -> Boolean): BackStack { + val (kept, gone) = entries.partition { keep(it.value) } + if (gone.isEmpty()) return this + val dropped = retired + gone.map { it.key } + return BackStack(kept.ifEmpty { listOf(Entry(unusedKey(taken(), newKey), fallback)) }, dropped) + } + + /** The same stack once the app has dropped the state filed under every [retired] key. */ + fun withoutRetired(): BackStack = if (retired.isEmpty()) this else BackStack(entries, emptyList()) + + /** Every key in use: on the stack, or still naming state the app has not dropped. */ + private fun taken(): Set = entries.mapTo(HashSet()) { it.key } + retired + + /** + * `{"entries":[{"key":…,"route":[…]}],"retired":[…]}`, each value written by [route] as a list of + * strings. [decode] reads it back. + */ + fun encode(route: (T) -> List): String = JsonObject( + mapOf( + "entries" to JsonArray(entries.map { e -> + JsonObject(mapOf("key" to JsonPrimitive(e.key), "route" to JsonArray(route(e.value).map { JsonPrimitive(it) }))) + }), + "retired" to JsonArray(retired.map { JsonPrimitive(it) }) + ) + ).toString() + + companion object { + fun randomKey(): String = UUID.randomUUID().toString() + + /** [values] bottom first, each under a new key; [fallback] when there are none. */ + fun of(values: List, fallback: T, newKey: () -> String = ::randomKey): BackStack { + var stack = BackStack(emptyList(), emptyList()) + for (v in values.ifEmpty { listOf(fallback) }) stack = stack.push(v, newKey) + return stack + } + + /** + * Reads what [encode] wrote. An entry whose route no longer decodes ([route] returns null) is + * dropped, never guessed, and its key is retired so the state filed under it goes too. The + * format before keys existed (a bare array of routes, audit A22) is read with new keys. Anything + * unreadable is [fallback] alone. + */ + fun decode( + raw: String, + fallback: T, + newKey: () -> String = ::randomKey, + route: (List) -> T?, + ): BackStack { + val root = runCatching { Json.parseToJsonElement(raw) }.getOrNull() + return when (root) { + is JsonArray -> of(root.mapNotNull { strings(it)?.let(route) }, fallback, newKey) + is JsonObject -> { + val retired = (root["retired"] as? JsonArray)?.mapNotNull(::text).orEmpty().toMutableList() + val live = mutableListOf>() + (root["entries"] as? JsonArray)?.forEach { e -> + val obj = e as? JsonObject ?: return@forEach + val key = obj["key"]?.let(::text) + val value = obj["route"]?.let(::strings)?.let(route) + when { + value == null -> key?.let { retired += it } + // A missing key, or one an entry below already has (a corrupted save), + // cannot be trusted to name this entry's state. + key == null || live.any { it.key == key } -> + live += Entry(unusedKey(live.mapTo(HashSet()) { it.key } + retired, newKey), value) + else -> live += Entry(key, value) + } + } + val liveKeys = live.mapTo(HashSet()) { it.key } + val dropped = retired.filterNot { it in liveKeys }.distinct() + if (live.isEmpty()) BackStack(emptyList(), dropped).push(fallback, newKey) + else BackStack(live, dropped) + } + else -> of(emptyList(), fallback, newKey) + } + } + + /** A key from [newKey] that is not in [taken]. A random UUID in practice; a test's source may repeat. */ + private fun unusedKey(taken: Set, newKey: () -> String): String { + repeat(16) { newKey().let { if (it.isNotBlank() && it !in taken) return it } } + return generateSequence { randomKey() }.first { it !in taken } + } + + private fun text(e: JsonElement): String? = + (e as? JsonPrimitive)?.takeIf { it.isString }?.content?.takeIf { it.isNotBlank() } + + private fun strings(e: JsonElement): List? = + (e as? JsonArray)?.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content ?: return null } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/CtrlModifier.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/CtrlModifier.kt new file mode 100644 index 0000000000..617bf7fd70 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/CtrlModifier.kt @@ -0,0 +1,11 @@ +package dev.nodeterm.protocol.model + +/** One immutable Ctrl arm: the bridge must capture its flag and revision together. */ +data class CtrlModifier(val armed: Boolean = false, val revision: Long = 0L) { + fun withArmed(value: Boolean): CtrlModifier = + if (value == armed) this else copy(armed = value, revision = revision + 1) + + /** A delayed input callback may consume only its original arm on its original viewer. */ + fun consume(captured: CtrlModifier, current: Boolean): CtrlModifier = + if (!current || !captured.armed || this != captured) this else withArmed(false) +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Dictation.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Dictation.kt new file mode 100644 index 0000000000..971a537fee --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Dictation.kt @@ -0,0 +1,200 @@ +package dev.nodeterm.protocol.model + +/** + * Dictation into the terminal screen's input bar (audit A59): the phone's own speech recognizer + * (`android.speech.SpeechRecognizer`) writes what it hears into the draft, and nothing else. It never + * sends: the draft goes to the pane only when the user taps Send, which is the desktop's rule for its + * own dictation ("nothing auto-submits"). + * + * This is the state machine and the text rules; the app's `DictationController` drives the recognizer + * as [Step.effect] says and shows [Step.draft]. One dictation is one utterance: the recognizer ends by + * itself once the user stops talking, or when the mic is tapped again. + * + * Phases: [Phase.Idle] → (Start) [Phase.Listening] → (words) [Phase.Partial] → (the final result) + * [Phase.Idle]. A tap on the mic while listening asks the recognizer for its final result + * ([Phase.Finishing]); a failure ends in [Phase.Error], which the next start or its OK clears. The + * "final" of the recognizer is an event, not a phase: it fills the draft and returns to idle. + * + * How heard text meets the draft: the draft as it was when the dictation started is the [State.base]; + * every partial result shows as `base` + the words heard so far, and the final result replaces them + * ([join]). So a draft typed before is kept and the dictation appends to it, an empty draft is filled, + * and a partial result the recognizer then corrects is not left behind. The screen puts the cursor at + * the end of every draft it writes from a [Step.draft], after the heard words, so typing after a + * dictation continues there. Any other change to the draft's text while listening (the user types, or + * Send clears it) ends the dictation ([Event.Edited]): later results would otherwise be joined to the + * old base and bring back text the user deleted or sent. Moving the cursor changes no text and is not + * an edit. + * What the draft shows when a dictation is cancelled or fails stays in it, partial words included: + * the user saw them there and can edit them. + */ +object Dictation { + sealed interface Phase { + /** Nothing is listening. The mic button starts a dictation. */ + data object Idle : Phase + + /** The recognizer was asked to listen and has heard no words yet. */ + data object Listening : Phase + + /** Words heard so far, shown in the draft. The recognizer may still revise them. */ + data class Partial(val text: String) : Phase + + /** + * The mic was tapped again: the recognizer was told to stop listening and its final result is + * on its way. Another tap cancels (the recognizer has to end with a result or an error, but a + * stuck one must not keep the mic button stuck too), keeping what the draft shows. + */ + data object Finishing : Phase + + /** The dictation ended without a result. [failure] says why, in a short sentence. */ + data class Error(val failure: Failure) : Phase + } + + data class State( + val phase: Phase = Phase.Idle, + /** The draft when this dictation started: heard words are added to it. */ + val base: String = "", + /** The words of the latest partial result, so a blank final result keeps what the draft shows. */ + val heard: String = "", + /** The explicit language at Start; null preserves the recognizer's system default. */ + val languageTag: String? = null, + ) { + /** The recognizer is running (or finishing): a tap on the mic stops it rather than starting one. */ + val active: Boolean get() = phase == Phase.Listening || phase is Phase.Partial || phase == Phase.Finishing + + /** The sentence to show, while a dictation has failed. */ + val message: String? get() = (phase as? Phase.Error)?.failure?.message + } + + /** What to do with the recognizer. */ + enum class Effect { + /** Create a recognizer and start listening (with partial results). */ + START, + + /** `stopListening()`: end the recording and deliver the final result. */ + STOP, + + /** `cancel()` and release it: no result is wanted any more. */ + CANCEL, + + /** It is done (a final result or an error): release it. */ + RELEASE, + } + + sealed interface Event { + /** The mic was tapped with nothing listening, and the microphone permission is granted. */ + data class Start(val draft: String, val languageTag: String? = null) : Event + + /** A partial result. */ + data class Heard(val text: String?) : Event + + /** The final result. */ + data class Result(val text: String?) : Event + + /** The mic was tapped while listening. */ + data object Stop : Event + + /** The screen stopped or went away. */ + data object Cancel : Event + + /** The draft changed other than by this dictation: the user typed, or Send cleared it. */ + data object Edited : Event + + /** The recognizer reported an error. */ + data class Failed(val failure: Failure) : Event + + /** The microphone permission was refused. */ + data object Denied : Event + + /** The error's OK. */ + data object Dismiss : Event + } + + /** + * One transition: the next [state], the text the draft must show (`null` = leave the draft as it + * is), and what to do with the recognizer (`null` = nothing). + */ + data class Step(val state: State, val draft: String? = null, val effect: Effect? = null) + + fun step(state: State, event: Event): Step { + val phase = state.phase + return when (event) { + is Event.Start -> + if (state.active) Step(state) + else Step(State(Phase.Listening, base = event.draft, languageTag = DictationLanguage.canonical(event.languageTag)), effect = Effect.START) + is Event.Heard -> { + // After a cancel, a stop that already ended, or an error: a late callback, ignored. + if (!state.active) return Step(state) + val heard = event.text.orEmpty().trim() + val next = when { + phase == Phase.Finishing -> Phase.Finishing + heard.isEmpty() -> Phase.Listening + else -> Phase.Partial(heard) + } + Step(state.copy(phase = next, heard = heard), draft = join(state.base, heard)) + } + is Event.Result -> { + if (!state.active) return Step(state) + // A blank final result does not take back the words a partial result already showed. + val heard = event.text.orEmpty().trim().ifEmpty { state.heard } + Step(State(), draft = join(state.base, heard), effect = Effect.RELEASE) + } + Event.Stop -> when (phase) { + Phase.Listening, is Phase.Partial -> Step(state.copy(phase = Phase.Finishing), effect = Effect.STOP) + Phase.Finishing -> Step(State(), effect = Effect.CANCEL) + else -> Step(state) + } + Event.Cancel, Event.Edited -> + if (state.active) Step(State(), effect = Effect.CANCEL) else Step(state) + is Event.Failed -> + if (state.active) Step(State(Phase.Error(event.failure)), effect = Effect.RELEASE) else Step(state) + Event.Denied -> + if (state.active) Step(state) else Step(State(Phase.Error(Failure.INSUFFICIENT_PERMISSIONS))) + Event.Dismiss -> + if (phase is Phase.Error) Step(State()) else Step(state) + } + } + + /** + * The draft showing [heard] words added to [base]: the words alone in an empty draft, else after + * a space unless the draft already ends in whitespace (a space or a newline the user typed). No + * words leave the draft as it was. + */ + fun join(base: String, heard: String): String { + val words = heard.trim() + return when { + words.isEmpty() -> base + base.isEmpty() || base.last().isWhitespace() -> base + words + else -> "$base $words" + } + } + + /** + * Why a dictation ended without a result: one per `SpeechRecognizer.ERROR_*` code ([code] is the + * platform's value; the names are the constants' without `ERROR_`), plus [UNKNOWN] for a code this + * list does not know (a newer Android's) and for a recognizer that could not be started at all. + * Every one has a short sentence for the input bar. + */ + enum class Failure(val code: Int?, val message: String) { + NETWORK_TIMEOUT(1, "Speech recognition timed out on the network."), + NETWORK(2, "Speech recognition could not reach the network."), + AUDIO(3, "Could not record from the microphone."), + SERVER(4, "The speech service reported an error. Try again."), + CLIENT(5, "Dictation stopped unexpectedly. Try again."), + SPEECH_TIMEOUT(6, "No speech heard."), + NO_MATCH(7, "Didn't catch that. Try again."), + RECOGNIZER_BUSY(8, "The speech recognizer is busy. Try again in a moment."), + INSUFFICIENT_PERMISSIONS(9, "nodeterm may not use the microphone. Allow it in Android's settings."), + TOO_MANY_REQUESTS(10, "Too many dictations for now. Try again later."), + SERVER_DISCONNECTED(11, "The speech service disconnected. Try again."), + LANGUAGE_NOT_SUPPORTED(12, "The speech recognizer does not support this language."), + LANGUAGE_UNAVAILABLE(13, "This language is not downloaded for speech recognition yet."), + CANNOT_CHECK_SUPPORT(14, "Speech recognition is not available."), + CANNOT_LISTEN_TO_DOWNLOAD_EVENTS(15, "Speech recognition is not available."), + UNKNOWN(null, "Speech recognition failed. Try again."); + + companion object { + /** The failure for a `RecognitionListener.onError` code. */ + fun of(code: Int): Failure = entries.firstOrNull { it.code == code } ?: UNKNOWN + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/DictationLanguage.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/DictationLanguage.kt new file mode 100644 index 0000000000..1e62b5af49 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/DictationLanguage.kt @@ -0,0 +1,49 @@ +package dev.nodeterm.protocol.model + +import java.util.Locale + +/** Language choices are locale data, not a claim about a speech service's installed models. */ +object DictationLanguage { + const val MAX_CHOICES = 1024 + const val MAX_VISIBLE = 60 + data class Choice(val tag: String, val label: String) + + /** Strict parsing: forLanguageTag alone silently truncates malformed input. Null means default. */ + fun canonical(raw: String?): String? { + val text = raw?.trim().orEmpty() + if (text.isEmpty() || text.length > 128) return null + return runCatching { Locale.Builder().setLanguageTag(text).build() } + .getOrNull()?.takeIf { it.language.isNotEmpty() && it.language != "und" } + ?.toLanguageTag() + } + + fun label(tag: String?, display: Locale = Locale.getDefault()): String = + canonical(tag)?.let { Locale.forLanguageTag(it).getDisplayName(display) } ?: "System default" + + /** Keep base languages before regional variants, so bounding a large locale catalog loses no roots. */ + fun choices(locales: List, display: Locale = Locale.getDefault(), selected: String? = null): List { + val rows = locales.asSequence().take(8192).map { it.stripExtensions() } + .mapNotNull { locale -> canonical(locale.toLanguageTag())?.let { Choice(it, locale.getDisplayName(display)) } } + .filter { it.label.isNotBlank() && it.label.length <= 160 } + .distinctBy { it.tag } + .sortedWith(compareBy({ it.tag.contains('-') }, { it.label.lowercase(display) }, { it.tag })) + .take(MAX_CHOICES).toMutableList() + // A retained explicit choice must remain visible even if locale catalogs change after an update. + canonical(selected)?.takeIf { tag -> rows.none { it.tag == tag } }?.let { tag -> + if (rows.size == MAX_CHOICES) rows.removeAt(rows.lastIndex) + rows += Choice(tag, label(tag, display)) + } + return rows.sortedWith(compareBy({ it.label.lowercase(display) }, { it.tag })) + } + + fun search(choices: List, query: String, selected: String? = null): List { + val text = query.trim().take(120) + val rows = choices.filter { text.isEmpty() || it.label.contains(text, ignoreCase = true) || it.tag.contains(text, ignoreCase = true) } + .take(MAX_VISIBLE).toMutableList() + if (text.isEmpty()) choices.firstOrNull { it.tag == selected && it !in rows }?.let { + if (rows.size == MAX_VISIBLE) rows.removeAt(rows.lastIndex) + rows.add(0, it) + } + return rows + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ExternalLink.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ExternalLink.kt new file mode 100644 index 0000000000..6d01a5b8fa --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ExternalLink.kt @@ -0,0 +1,53 @@ +package dev.nodeterm.protocol.model + +/** + * A link from the terminal page that the phone may open in a browser (audit A32): an `http` or + * `https` URL, and nothing else. The page (terminal.js) only hands over a URL its URL parser wrote out + * (`new URL(text).href`): printable ASCII, the host lower-cased and punycoded, the rest + * percent-encoded, a backslash in the authority turned into a slash. This check is the app's own, so + * a page that did not do that (a bug, or the bridge called with something else) still cannot hand + * Android an `intent:`, `file:`, `content:` or `javascript:` URI to open. + * + * [host] is what the screen names before it opens the link ("Open ?"). An OSC 8 link shows a + * label and hides its URL, so the user is shown where it really goes, user info stripped: in + * `https://github.com@evil.example/`, the host is `evil.example`. + */ +data class ExternalLink(val url: String, val host: String) { + companion object { + /** Longer than any URL a page is expected to show; an OAuth URL is under 2,000 characters. */ + const val MAX_LENGTH = 8_192 + + private val SCHEME = Regex("^https?://", RegexOption.IGNORE_CASE) + private val PORT = Regex("^[0-9]*$") + + fun parse(url: String): ExternalLink? { + if (url.isEmpty() || url.length > MAX_LENGTH) return null + // Printable ASCII only: no space, no control character, nothing the URL parser would + // have encoded. + if (url.any { it.code !in 0x21..0x7e }) return null + val scheme = SCHEME.find(url) ?: return null + val rest = url.substring(scheme.range.last + 1) + val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' } + val authority = if (end < 0) rest else rest.substring(0, end) + // The URL parser never leaves one here; a browser reads it as a slash, so the host + // named below would not be the one it opens. + if ('\\' in authority) return null + val hostPort = authority.substringAfterLast('@') + val host: String + val port: String + if (hostPort.startsWith('[')) { + val close = hostPort.indexOf(']') + if (close < 0) return null + host = hostPort.substring(0, close + 1) + val after = hostPort.substring(close + 1) + if (after.isNotEmpty() && !after.startsWith(':')) return null + port = after.removePrefix(":") + } else { + host = hostPort.substringBefore(':') + port = hostPort.substringAfter(':', "") + } + if (host.isEmpty() || host == "[]" || !PORT.matches(port)) return null + return ExternalLink(url, host.lowercase()) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/HookReplies.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/HookReplies.kt new file mode 100644 index 0000000000..719fb33dc7 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/HookReplies.kt @@ -0,0 +1,114 @@ +package dev.nodeterm.protocol.model + +import kotlinx.serialization.json.* + +data class PermissionSuggestion(val index: Int, val label: String) +data class HookQuestionOption(val label: String, val description: String) +data class HookQuestion(val question: String, val header: String, val options: List, val multiSelect: Boolean) + +/** Mirrors shared/hook-answers.ts. Input is the exact held file; callers never supply hook JSON. */ +object HookReplies { + const val MAX_BYTES = 128 * 1024 + const val MARKER = "nodeterm-hook-reply-v2" + private fun JsonObject.s(key: String) = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + private fun valid(s: String?, max: Int) = s != null && s.isNotBlank() && s.length <= max + + fun belongsToNode(nodeId: String, ticket: String): Boolean = + Regex("^[A-Za-z0-9_-]{1,200}$").matches(nodeId) && ticket.length <= 256 && + ticket.startsWith("$nodeId-") && Regex("^[0-9]+-[0-9]+$").matches(ticket.substring(nodeId.length + 1)) + + fun questions(value: JsonElement?): List? { + val rows = value as? JsonArray ?: return null + if (rows.size !in 1..4) return null + val seen = mutableSetOf() + return rows.map { raw -> + val q = raw as? JsonObject ?: return null + val text = q.s("question") ?: return null + val header = q.s("header") ?: return null + if (!valid(text, 4096) || !valid(header, 80) || !seen.add(text)) return null + val multi = q["multiSelect"] + if (multi != null && (multi !is JsonPrimitive || multi.isString || multi.booleanOrNull == null)) return null + val options = q["options"] as? JsonArray ?: return null + if (options.size !in 2..4) return null + val labels = mutableSetOf() + val parsed = options.map { option -> + val o = option as? JsonObject ?: return null + val label = o.s("label") ?: return null + val description = o.s("description") ?: return null + if (!valid(label, 256) || description.length > 4096 || !labels.add(label)) return null + HookQuestionOption(label, description) + } + HookQuestion(text, header, parsed, (multi as? JsonPrimitive)?.booleanOrNull == true) + } + } + + fun permissionSuggestions(value: JsonElement?): List = + (value as? JsonArray)?.takeIf { it.size <= 32 }?.mapNotNull { raw -> + val row = raw as? JsonObject ?: return@mapNotNull null + val index = (row["index"] as? JsonPrimitive)?.intOrNull ?: return@mapNotNull null + val label = row.s("label") ?: return@mapNotNull null + if (index !in 0..31 || !valid(label, 34000)) null else PermissionSuggestion(index, label) + } ?: emptyList() + + private fun permissionUpdate(request: JsonObject, index: Int): JsonObject? { + if (request.s("hook_event_name") != "PermissionRequest") return null + val tool = request.s("tool_name") ?: return null + if (!valid(tool, 128)) return null + val suggestions = request["permission_suggestions"] as? JsonArray ?: return null + if (suggestions.size > 32 || index !in suggestions.indices) return null + val update = suggestions[index] as? JsonObject ?: return null + if (update.s("type") != "addRules" || update.s("behavior") != "allow" || + update.s("destination") !in listOf("session", "localSettings", "projectSettings", "userSettings")) return null + val rules = update["rules"] as? JsonArray ?: return null + if (rules.size !in 1..8) return null + val checked = rules.map { raw -> + val rule = raw as? JsonObject ?: return null + val content = rule.s("ruleContent") ?: return null + if (rule.s("toolName") != tool || !valid(content, 4096) || content == "*") return null + buildJsonObject { put("toolName", tool); put("ruleContent", content) } + } + return buildJsonObject { + put("type", "addRules"); put("rules", JsonArray(checked)); put("behavior", "allow") + put("destination", update.s("destination")!!) + } + } + + fun remember(request: JsonObject, suggestionIndex: Int): String? { + val update = permissionUpdate(request, suggestionIndex) ?: return null + return reply(buildJsonObject { + put("hookEventName", "PermissionRequest") + putJsonObject("decision") { put("behavior", "allow"); put("updatedPermissions", JsonArray(listOf(update))) } + }) + } + + fun answerQuestions(request: JsonObject, selections: List>): String? { + if (request.s("hook_event_name") != "PreToolUse" || request.s("tool_name") != "AskUserQuestion" || + !request.s("agent_id").isNullOrEmpty()) return null + val input = request["tool_input"] as? JsonObject ?: return null + val questions = questions(input["questions"]) ?: return null + if (!validSelections(questions, selections)) return null + val answers = buildJsonObject { + questions.forEachIndexed { i, q -> + val selected = selections[i] + put(q.question, selected.sorted().joinToString(", ") { q.options[it].label }) + } + } + return reply(buildJsonObject { + put("hookEventName", "PreToolUse"); put("permissionDecision", "allow") + put("updatedInput", JsonObject(input + ("answers" to answers))) + }) + } + + fun validSelections(questions: List, selections: List>): Boolean = + questions.size in 1..4 && questions.size == selections.size && questions.indices.all { i -> + val q = questions[i] + val selected = selections[i] + selected.isNotEmpty() && selected.size <= q.options.size && selected.distinct().size == selected.size && + (q.multiSelect || selected.size == 1) && selected.all { it in q.options.indices } + } + + private fun reply(output: JsonObject): String? { + val json = buildJsonObject { put("hookSpecificOutput", output) }.toString() + return if (json.toByteArray(Charsets.UTF_8).size <= MAX_BYTES) "$MARKER\n$json" else null + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InboxNotificationText.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InboxNotificationText.kt new file mode 100644 index 0000000000..8fbd7d1e76 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InboxNotificationText.kt @@ -0,0 +1,70 @@ +package dev.nodeterm.protocol.model + +/** + * What an Inbox notification says (audit A52). + * + * An event's own text is the sensitive part. For an approval, [InboxEvent.title] and + * [InboxEvent.detail] are the desktop's tool summary: the command's first line, a file path with its + * diff size, a fetched URL (`buildApprovalSummary`, src/core/agent-status-mirror.ts). Without that + * summary the title is the first line of the agent's last message. For a question it is the question + * itself, and for a finished turn the detail is the agent's last message. Android shows a + * notification's full content on a secure lock screen unless the user has turned on "hide sensitive + * content", and that is not the default. A public version alone therefore hides nothing in the + * default case. So by default the notification carries none of that text: the title names the + * session and whether it needs you or completed, and the text names the kind of event. The event's + * own text is added only when the user opts in ("Show details in notifications"). + * + * The public version (what a lock screen that hides sensitive content shows) is built here too, and + * never carries the event's text, whatever the setting. + * + * The iOS app does get the detail: the desktop sends the same title and detail in the APNs push body + * (src/core/push-notify.ts), and what iOS then shows on its lock screen is up to iOS's own preview + * setting. That is a difference in platform defaults, not an Android-only leak; this app does not + * copy it. + */ +data class InboxNotificationText( + val title: String, + val text: String, + /** The expanded text (BigTextStyle), or null when the notification has none. */ + val bigText: String?, + /** The public version's title: [title]'s session and kind, nothing from the event. */ + val publicTitle: String, + /** The public version's text: the computer's name. */ + val publicText: String +) { + companion object { + /** The session's name when the listing has none. */ + const val FALLBACK_SESSION = "Session" + + /** + * [sessionName] is the session's name as the listing gives it (null or blank = unknown), + * [computerName] the paired computer's. [showDetails] is the user's opt-in; off by default. + */ + fun of(event: InboxEvent, sessionName: String?, computerName: String, showDetails: Boolean): InboxNotificationText { + val session = sessionName?.trim()?.takeIf { it.isNotEmpty() } ?: FALLBACK_SESSION + val headline = when (event.kind) { + InboxKind.APPROVAL, InboxKind.QUESTION -> "Needs you — $session" + InboxKind.DONE -> "Completed — $session" + } + val kind = kindLabel(event) + val own = if (showDetails) listOf(event.title, event.detail.orEmpty()).map { it.trim() }.filter { it.isNotEmpty() } else emptyList() + return InboxNotificationText( + title = headline, + text = own.joinToString(" — ").ifEmpty { kind }, + bigText = own.takeIf { it.isNotEmpty() }?.joinToString("\n"), + publicTitle = headline, + publicText = computerName + ) + } + + /** + * The kind of event in words, close to the Inbox card's chip but not always the same words + * (the chip says QUESTION). Never the event's own text. + */ + fun kindLabel(event: InboxEvent): String = when (event.kind) { + InboxKind.APPROVAL -> "Needs approval" + InboxKind.QUESTION -> "Has a question" + InboxKind.DONE -> if (event.interrupted) "Interrupted" else "Finished" + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InputBar.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InputBar.kt new file mode 100644 index 0000000000..35a654e1a7 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/InputBar.kt @@ -0,0 +1,34 @@ +package dev.nodeterm.protocol.model + +/** + * What the terminal screen's input bar does with the text in it (audits A34, A41). The screen + * (TerminalController) is only type-checked; this pins the rules it delegates to. + */ +object InputBar { + sealed interface Send { + /** + * No stream is attached (still connecting, disconnected, ended, or awaiting approval): nothing + * is sent. The caller keeps its draft and an armed Ctrl stays armed, so the same tap works once + * the terminal is back. Sending anyway reached a null stream, dropped the bytes, and the screen + * then cleared the draft as if it had gone (A41). + */ + data object NotAttached : Send + + /** + * Ctrl armed and ONE character that has a control byte: that byte alone, with no Enter (^Z then + * Enter is not ^Z). The bar goes through xterm's bracketed paste, so the per-keystroke Ctrl in + * the bridge would never see a single character (A34). + */ + data class Control(val bytes: String) : Send + + /** The text through xterm's bracketed paste, then Enter when asked. An armed Ctrl is disarmed. */ + data class Paste(val text: String, val enter: Boolean) : Send + } + + /** What sending [text] does, given whether a stream is [attached] and whether Ctrl is [ctrlArmed]. */ + fun plan(attached: Boolean, ctrlArmed: Boolean, text: String, enter: Boolean): Send = when { + !attached -> Send.NotAttached + ctrlArmed -> Keys.ctrl(text)?.let { Send.Control(it) } ?: Send.Paste(text, enter) + else -> Send.Paste(text, enter) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/JsonReads.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/JsonReads.kt new file mode 100644 index 0000000000..5ce7c4e770 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/JsonReads.kt @@ -0,0 +1,39 @@ +package dev.nodeterm.protocol.model + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Tolerant field reads. Everything the phone parses comes from files the desktop documents as + * hand-editable and hostile (project.json is git-shared), so a wrong-typed field degrades to + * "absent" — it never throws and never takes the whole listing down with it. + */ +object J { + fun parse(text: String): JsonElement? = try { + if (text.isBlank()) null else Json.parseToJsonElement(text) + } catch (_: Exception) { + null + } + + fun obj(e: JsonElement?): JsonObject? = e as? JsonObject + fun arr(e: JsonElement?): JsonArray? = e as? JsonArray + fun str(e: JsonElement?): String? = (e as? JsonPrimitive)?.takeIf { it.isString }?.content + fun long(e: JsonElement?): Long? = (e as? JsonPrimitive)?.takeIf { !it.isString }?.let { it.longOrNull ?: it.doubleOrNull?.toLong() } + fun double(e: JsonElement?): Double? = (e as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull + fun bool(e: JsonElement?): Boolean? = (e as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull + + fun JsonObject.s(key: String): String? = str(this[key]) + fun JsonObject.l(key: String): Long? = long(this[key]) + fun JsonObject.d(key: String): Double? = double(this[key]) + fun JsonObject.b(key: String): Boolean? = bool(this[key]) + fun JsonObject.o(key: String): JsonObject? = obj(this[key]) + fun JsonObject.a(key: String): List = arr(this[key]) ?: emptyList() + fun JsonObject.objects(key: String): List = a(key).mapNotNull { it as? JsonObject } + fun JsonObject.strings(key: String): List = a(key).mapNotNull { str(it) } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Keys.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Keys.kt new file mode 100644 index 0000000000..b4e73ead84 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Keys.kt @@ -0,0 +1,18 @@ +package dev.nodeterm.protocol.model + +/** Terminal key encodings the phone produces itself. */ +object Keys { + /** + * The control byte for Ctrl + [text], when [text] is ONE character that has one: `@`..`_` in + * either case (Ctrl-A = 0x01, Ctrl-[ = ESC, …) and `?` (DEL). Null otherwise. + */ + fun ctrl(text: String): String? { + if (text.length != 1) return null + val ch = text[0].uppercaseChar() + return when { + ch in '@'..'_' -> (ch.code - 64).toChar().toString() + ch == '?' -> "\u007f" + else -> null + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt new file mode 100644 index 0000000000..4a3e998530 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Models.kt @@ -0,0 +1,331 @@ +package dev.nodeterm.protocol.model + +/** + * The phone's view of one paired computer: its projects (the ASSEMBLED v2 workspace the desktop's + * `projects.list` serves, src/core/projects-list-blob.ts), which tmux sessions are live, and the + * agent-status mirror file (`agent-status.json`, see `src/core/agent-status-mirror.ts` and + * docs/mobile-usage-inbox.md). + */ +data class ProjectsSnapshot( + val projects: List, + /** Live nodeterm tmux session names (`nt-`), from `tmux ls` on the host. */ + val liveSessions: Set, + val status: AgentStatusFile?, + val fetchedAt: Long, + /** + * Which tmux socket each live session is on ([TmuxNames.SOCKETS]), session name → socket. Only + * the direct-SSH browse fills it: one host can carry both the `node-terminal` sessions of a + * nodeterm running ON it and the `nodeterm-rmt` sessions of a desktop that drives it over SSH + * (audit A27), and a session is reached on the socket it was listed on. Empty for a relay + * listing (the desktop attaches its own sessions itself). + */ + val sockets: Map = emptyMap(), + /** + * What the computer said about its own LAN leg beside the listing (audit A74-refresh): its current + * address and SSH host keys. Only a relay listing carries it, read off the answer next to the blob + * ([dev.nodeterm.protocol.host.RelayHostConnection.listProjects]); a direct-SSH listing never does, + * and [dev.nodeterm.protocol.ssh.LanRefresh] refuses one anyway, so nothing taken over SSH moves + * the address or the keys that check SSH. + */ + val lan: dev.nodeterm.protocol.ssh.LanReport? = null +) { + fun isLive(nodeId: String): Boolean = liveSessions.contains(TmuxNames.sessionName(nodeId)) + + /** The socket [nodeId]'s session was listed on, or null when the listing did not see it. */ + fun socketOf(nodeId: String): String? = sockets[TmuxNames.sessionName(nodeId)] + + fun statusOf(nodeId: String): AgentNodeStatus? = status?.nodes?.get(nodeId) + + fun openProjects(): List = projects.filter { !it.closed } + + fun findNode(nodeId: String): Pair? { + for (p in projects) p.nodes.firstOrNull { it.id == nodeId }?.let { return p to it } + return null + } + + companion object { + val EMPTY = ProjectsSnapshot(emptyList(), emptySet(), null, 0) + } +} + +data class ProjectInfo( + val id: String, + val name: String, + val color: String?, + val cwd: String?, + /** `user@host` for an SSH project (its terminals run on ANOTHER machine), else null. */ + val sshTarget: String?, + val closed: Boolean, + val nodes: List, + /** The persisted board, or null when the project has never had one (the desktop's lazy default). */ + val board: KanbanBoard?, + /** The project's own permission mode (project.json `defaultPermissionMode`), overriding the + * global one exactly as the desktop's `resolvePermissionMode` does. Unvalidated here. */ + val defaultPermissionMode: String? = null, + /** The machine-local default Claude account for new sessions in this project. */ + val defaultAccountId: String? = null, + /** + * A project of a nodeterm desktop ELSEWHERE that drives this computer over SSH (audit A27): its + * sessions run here, on the `nodeterm-rmt` socket, and its canvas is that desktop's. The direct-SSH + * browse finds it from `/.nodeterm/project.json`, the status slice that desktop pushes + * here (`~/.nodeterm/agent-status-.json`) and `tmux ls`. What the machine does — attach, + * keys, approvals, read-acks, ending the tmux session — works on it over SSH; what needs nodeterm + * the app (new sessions, the board, node actions, git) belongs to that other desktop, which this + * connection does not reach ([dev.nodeterm.protocol.host.LegRouting.forProject]). + */ + val drivenRemotely: Boolean = false +) { + /** Session nodes — what the phone lists and can attach to. */ + val sessions: List get() = nodes.filter { it.kind == NodeKind.TERMINAL } + + /** + * Where [node] runs, as an absolute path: its own cwd, resolved against the project folder when + * it is stored portable (`./sub`, the git-shared form), else the project folder. Null when + * neither is absolute. + */ + fun absoluteCwdOf(node: NodeInfo?): String? { + val own = node?.cwd + val root = cwd?.takeIf { it.startsWith("/") } + return when { + own != null && own.startsWith("/") -> own + own != null && (own == "." || own.startsWith("./")) -> root?.let { it.trimEnd('/') + own.removePrefix(".") } + else -> root + } + } +} + +enum class NodeKind(val wire: String) { + TERMINAL("terminal"), STICKY("sticky"), GROUP("group"), BROWSER("browser"), OTHER(""); + + companion object { + /** A missing kind is a terminal — the same backward-compat default `nodeStatesToFlow` applies. */ + fun of(wire: String?): NodeKind = when (wire ?: "terminal") { + "terminal" -> TERMINAL + "sticky" -> STICKY + "group" -> GROUP + "browser" -> BROWSER + else -> OTHER + } + } +} + +data class NodeInfo( + val id: String, + val kind: NodeKind, + val title: String, + val color: String?, + val agentId: String?, + val accountId: String?, + val cwd: String?, + val parentId: String?, + /** A one-grapheme emoji icon, when the node has one (image icons are not drawn on the phone). */ + val iconEmoji: String?, + /** The session id nodeterm minted at launch (`agentSessionId`), a resume fallback. */ + val agentSessionId: String?, + /** Sticky text (sticky nodes only). */ + val text: String? +) + +enum class AgentState(val wire: String) { + WORKING("working"), WAITING("waiting"), BLOCKED("blocked"), DONE("done"); + + companion object { + fun of(wire: String?): AgentState? = entries.firstOrNull { it.wire == wire } + } +} + +/** + * The sessions sidebar's grouping (CLAUDE.md "Status-grouped sessions"): a finished turn, a + * question and an approval ALL wait on the user; `working` is running; no live hook state is + * unknown. There is deliberately no "done" bucket. + */ +enum class SessionBucket { NEEDS_YOU, RUNNING, SLEEPING, UNKNOWN } + +data class AgentNodeStatus( + val state: AgentState?, + val agentId: String?, + val sessionId: String?, + /** The agent's own session name (the `/rename` name), when resolved. */ + val name: String?, + val hibernated: Boolean, + val updatedAt: Long, + /** Which Claude account the session was OBSERVED running under, when a hook reported one. + * Name it with [AccountNames.observed]; the mirror carries no display name of its own here. */ + val account: ObservedAccount? +) { + val bucket: SessionBucket + get() = when { + hibernated -> SessionBucket.SLEEPING + state == AgentState.WORKING -> SessionBucket.RUNNING + state == AgentState.DONE || state == AgentState.WAITING || state == AgentState.BLOCKED -> SessionBucket.NEEDS_YOU + else -> SessionBucket.UNKNOWN + } +} + +/** + * `ObservedClaudeAccount` (src/shared/types.ts): the account a running session's hooks reported, + * classified by the desktop from the transcript path. `accountId` null + [known] = the system + * `~/.claude`; `accountId` null + not [known] = a config dir the desktop has no record of. + */ +data class ObservedAccount(val configDir: String?, val accountId: String?, val known: Boolean) + +data class AgentStatusFile( + val updatedAt: Long, + val nodes: Map, + val settings: MirrorSettings?, + val usage: MirrorUsage?, + val inbox: MirrorInbox?, + /** The Server Edition's install metadata (the mirror's top-level `server` block), when present. */ + val server: MirrorServer? +) + +/** + * `MirrorServer` (src/core/agent-status-mirror.ts): what `scripts/install-server.sh` recorded in + * `/install-meta.json`, surfaced by a Server Edition so a phone can show which version it + * is on. Every field is optional there; a server started without the installer has no block at all. + */ +data class MirrorServer(val version: String?, val commit: String?, val installedAt: String?) { + /** One line for the host screen ("nodeterm server 0.2.17 · 1e56f83 · installed 2026-09-01"), or null with nothing to say. */ + fun describe(): String? { + if (version == null && commit == null && installedAt == null) return null + return buildList { + add("nodeterm server" + (version?.let { " $it" } ?: "")) + commit?.let { add(it) } + // An ISO-8601 timestamp: its date is what a person compares. Anything else is shown as given. + installedAt?.let { add("installed " + (Regex("^\\d{4}-\\d{2}-\\d{2}").find(it)?.value ?: it)) } + }.joinToString(" · ") + } +} + +data class MirrorSettings( + val claudePermissionMode: String?, + /** Answers for CLAUDE only: may `--permission-mode auto` be emitted on this host. */ + val autoSupported: Boolean?, + val claudeAccounts: List, + /** Values this host's codex accepts for `--ask-for-approval`; empty = not probed. */ + val codexApprovalValues: List +) + +/** + * One `MirrorSettings.claudeAccounts` entry (`mirrorClaudeAccount`, agent-status-mirror.ts): [id] and + * [dir] are what a launch uses; [label] and [email] are what the phone shows (absent on a desktop + * older than them). Show it through [AccountNames], never by printing [id]. + */ +data class ManagedAccount(val id: String, val dir: String, val label: String? = null, val email: String? = null) + +data class MirrorUsage(val updatedAt: Long, val accounts: List) + +data class UsageAccount( + val accountId: String?, + val label: String?, + val email: String?, + val agentId: String, + val status: String, + val updatedAt: Long, + val limits: List +) + +data class UsageLimit( + val kind: String, + val group: String?, + val usedPercent: Double, + val severity: String?, + val resetsAt: Long?, + val windowMinutes: Long?, + val scopeLabel: String?, + val isActive: Boolean +) + +data class MirrorInbox(val events: List, val nodes: Map) + +enum class InboxKind(val wire: String) { + APPROVAL("approval"), QUESTION("question"), DONE("done"); + + companion object { + fun of(wire: String?): InboxKind? = entries.firstOrNull { it.wire == wire } + } +} + +data class InboxEvent( + val id: String, + val ts: Long, + val nodeId: String, + val agentId: String?, + val sessionId: String?, + val kind: InboxKind, + val title: String, + val detail: String?, + val interrupted: Boolean, + val resolved: Boolean, + val options: List, + val multiSelect: Boolean, + /** The hook-reply ticket (docs/hook-reply-approvals.md): answer by writing the `.answer` file. */ + val pendingId: String?, + val permissionSuggestions: List = emptyList(), + val questionPendingId: String? = null, + val questions: List = emptyList() +) { + val actionable: Boolean get() = !resolved && (kind == InboxKind.APPROVAL || kind == InboxKind.QUESTION) +} + +data class InboxNodeNow( + val activity: String?, + val tool: String?, + val contextPercent: Double?, + val prompt: String?, + val updatedAt: Long +) + +data class KanbanColumn(val id: String, val title: String, val color: String?) + +data class KanbanLabel(val id: String, val name: String, val color: String) + +data class KanbanCardMeta( + val nodeId: String, + val assignees: List, + val dueAt: Long?, + val priority: String?, + val labels: List +) + +data class KanbanBoard( + val columns: List, + /** nodeId → columnId, in persisted order (order within a column = relative order here). */ + val assignments: List>, + val labels: List, + val meta: List +) { + fun columnOf(nodeId: String): String? = assignments.firstOrNull { it.first == nodeId }?.second + + fun metaOf(nodeId: String): KanbanCardMeta? = meta.firstOrNull { it.nodeId == nodeId } + + companion object { + /** `@shared/kanban-default-board`: the columns a project with no board is shown. Display only — + * a write first asks the host to seed the real board (`projects.ensureBoard`). */ + val DEFAULT_COLUMN_TITLES = listOf("To Do", "In Progress", "Done") + } +} + +/** `src/core/tmux-naming.ts`. */ +object TmuxNames { + /** The socket of a nodeterm running ON the computer (the desktop app or the Server Edition). */ + const val SOCKET = "node-terminal" + /** + * The socket a DESKTOP uses for the sessions it runs on an SSH host (`remoteTmuxCommand`, + * src/shared/ssh.ts). The phone reaches it when it SSHes into that host itself (audit A27); a + * node of the paired desktop's own SSH projects is still reached through the desktop (A09). + */ + const val REMOTE_SOCKET = "nodeterm-rmt" + + /** Plain shells explicitly created by a phone; no desktop reaper or managed hook environment. */ + const val PHONE_SOCKET = "nodeterm-phone" + + /** Every socket a session may be on, in the order a name on both is attributed (first wins, as + * the desktop's session-memory sweep does). Nothing else is ever spliced into `tmux -L`. */ + val SOCKETS = listOf(SOCKET, REMOTE_SOCKET, PHONE_SOCKET) + + fun sessionName(persistKey: String): String = "nt-" + persistKey.replace(Regex("[^a-zA-Z0-9_-]"), "_") + + /** `^nt-[A-Za-z0-9_-]+$` — the only target shape we ever interpolate into a tmux command. */ + fun isSessionName(target: String): Boolean = Regex("^nt-[A-Za-z0-9_-]+$").matches(target) +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/NewSession.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/NewSession.kt new file mode 100644 index 0000000000..37cad9e0ea --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/NewSession.kt @@ -0,0 +1,43 @@ +package dev.nodeterm.protocol.model + +/** + * What the phone's New-session dialog offers, and what its Start button uses (audit A42). + * + * The dialog stays open while the host listing is re-fetched under it (the Host screen re-lists every + * 8 s), but the user's taps are remembered. So a remembered pick can name a project the desktop has + * since closed or removed, or a managed account it has since removed. Start used to look the project + * up with `first {}`, which threw inside the click handler; and a vanished account id was still sent + * to the desktop, under a picker that drew no row as selected (or, with every account gone, drew no + * picker at all). + * + * So the dialog derives its selection from the CURRENT listing on every recomposition, through these + * functions, and draws and starts with the same answer: the row shown as selected is always the one + * Start uses, and Start is off when there is nothing to start in. + */ +object NewSessionChoice { + /** + * The projects a phone can start a session in: open, on this computer (an SSH project's terminals + * run on another machine), and with a folder — the desktop refuses to register a node in a + * cwd-less canvas, which would orphan the session (A14). Never a project another desktop drives + * over SSH (A27): this computer's nodeterm would be asked to register a node in a canvas it does + * not have. + */ + fun offeredProjects(snapshot: ProjectsSnapshot): List = + snapshot.openProjects().filter { it.sshTarget == null && it.cwd != null && !it.drivenRemotely } + + /** + * The project Start uses: the one the user picked while it is still offered, else the first + * offered project (what the dialog preselects), else null — nothing to start in, so Start is off. + */ + fun project(offered: List, pickedId: String?): ProjectInfo? = + offered.firstOrNull { it.id == pickedId } ?: offered.firstOrNull() + + /** + * The managed Claude account Start uses in [project]: the user's pick while this host still has + * that account (null is System, which is always there), else the project's default, itself only + * while the host still has it ([Launch.defaultAccount]) — else System. + */ + fun account(settings: MirrorSettings?, project: ProjectInfo?, pickedId: String?): String? = + if (pickedId == null || settings?.claudeAccounts?.any { it.id == pickedId } == true) pickedId + else Launch.defaultAccount(settings, project) +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/OnScreen.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/OnScreen.kt new file mode 100644 index 0000000000..d0ecb8133a --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/OnScreen.kt @@ -0,0 +1,145 @@ +package dev.nodeterm.protocol.model + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * What of one paired computer the phone is showing right now, for the live notifications (audit A73). + * + * The app re-lists the computer whose screen is open every 8 s, and every fresh listing runs the same + * announce path as the 15-minute background check, except for what the user is looking at: + * + * - every event while that computer's Inbox tab is on screen (the tab is where they are listed); + * - the events of a session whose terminal is attached ([nodes]), because its pane shows them: the + * prompt, the question's picker, the finish. Not all of them, though ([inPane]): a held hook-reply + * approval is not painted until its hold ends. + * + * Those are recorded as seen instead of announced ([SeenLog.claimLive]), so no later check (the next + * refresh, or the background one) announces what the user already saw. Recording is permanent, so + * this must never claim more than the screen shows (the A73 review): a session whose terminal is + * still connecting ([opening]) is neither recorded nor announced, and is decided by a later listing, + * once its pane shows or an error does instead. Other computers are not polled at all, so nothing + * here makes their notifications live: theirs still come from the background check only. + */ +data class OnScreen( + val inbox: Boolean = false, + /** Sessions open in a terminal that is attached: their pane is in front of the user. */ + val nodes: Set = emptySet(), + /** Sessions open in a terminal that is still connecting: their pane, or an error, is about to show. */ + val opening: Set = emptySet() +) { + /** The user is looking at [ev]. */ + fun shows(ev: InboxEvent): Boolean = inbox || (ev.nodeId in nodes && inPane(ev)) + + /** [ev] would be in the pane of a terminal that is still connecting: not decided yet. */ + fun waitsFor(ev: InboxEvent): Boolean = !shows(ev) && ev.nodeId in opening && inPane(ev) + + /** [events] (a computer's feed) split by what is on screen; each part keeps the feed's order. */ + fun split(events: List): Split { + val shown = ArrayList() + val offScreen = ArrayList() + val waiting = ArrayList() + for (ev in events) { + when { + shows(ev) -> shown += ev + waitsFor(ev) -> waiting += ev + else -> offScreen += ev + } + } + return Split(shown, offScreen, waiting) + } + + data class Split( + /** On screen: recorded as seen, never announced. */ + val shown: List, + /** Not on screen: announced when new ([SeenLog.claimLive] decides). */ + val offScreen: List, + /** In the pane of a terminal still connecting: left alone, for a later listing to decide. */ + val waiting: List = emptyList() + ) + + /** Where a session open in a terminal stands, for [OnScreenTracker.showNode]. */ + enum class Pane { + /** Attached: the pane is in front of the user. */ + SHOWN, + /** Connecting: the pane, or an error instead, is about to show. */ + OPENING, + /** Something else covers the pane (the session ended, an offer, a code to approve, a lost view). */ + HIDDEN + } + + companion object { + /** Nothing of this computer on screen: the app is in the background, or shows something else. */ + val NOTHING = OnScreen() + + /** + * Whether [ev] is painted in its session's pane. Every event is, except a held hook-reply + * approval (it carries a `pendingId`): Claude Code applies the hook's decision before it ever + * paints the prompt, and paints it only once the hold ends, 45 s by default + * (docs/hook-reply-approvals.md; [dev.nodeterm.protocol.host.QuickActions] answers such an + * approval with its ticket, never with keys, for the same reason). A subagent's concurrent + * approval is one too, published on the parent node while its pane shows the parent's + * question. The ticket stays on the event after the hold ends, when the prompt is painted, so + * such an approval may be announced although it is on screen by then: a notification too many + * beats one lost. + */ + fun inPane(ev: InboxEvent): Boolean = !(ev.kind == InboxKind.APPROVAL && ev.pendingId != null) + } +} + +/** + * The screens showing a computer's Inbox tab or one of its sessions right now. A screen takes a + * handle when it starts and closes it when it stops. Screens are counted, not flagged, because the + * next screen can start before the last one stops; closing a handle twice counts once. Thread-safe: + * the screens register on the main thread and a refresh reads [now] from a background thread. + */ +class OnScreenTracker { + private var inboxes = 0 + private val terminals = ArrayList() + + private class Terminal(val nodeId: String, val pane: () -> OnScreen.Pane) + + /** + * What is on screen at this moment. A session is in [OnScreen.nodes] when any of its terminals is + * [OnScreen.Pane.SHOWN], else in [OnScreen.opening] when one is [OnScreen.Pane.OPENING]. + */ + @Synchronized + fun now(): OnScreen { + if (inboxes == 0 && terminals.isEmpty()) return OnScreen.NOTHING + val shown = HashSet() + val opening = HashSet() + for (t in terminals) { + when (t.pane()) { + OnScreen.Pane.SHOWN -> shown += t.nodeId + OnScreen.Pane.OPENING -> opening += t.nodeId + OnScreen.Pane.HIDDEN -> Unit + } + } + opening -= shown + return if (inboxes == 0 && shown.isEmpty() && opening.isEmpty()) OnScreen.NOTHING + else OnScreen(inboxes > 0, shown, opening) + } + + /** The computer's Inbox tab is on screen until the handle is closed. */ + fun showInbox(): AutoCloseable { + synchronized(this) { inboxes++ } + return handle { inboxes-- } + } + + /** + * The session [nodeId] is open in a terminal until the handle is closed; [pane] says, whenever a + * listing asks, whether that terminal shows its pane. It is called from the refresh's thread under + * this tracker's lock, so it must only read state. Without it the terminal counts as attached. + */ + fun showNode(nodeId: String, pane: () -> OnScreen.Pane = { OnScreen.Pane.SHOWN }): AutoCloseable { + val t = Terminal(nodeId, pane) + synchronized(this) { terminals += t } + return handle { terminals.remove(t) } + } + + private fun handle(release: () -> Unit): AutoCloseable { + val closed = AtomicBoolean(false) + return AutoCloseable { + if (closed.compareAndSet(false, true)) synchronized(this@OnScreenTracker) { release() } + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Osc52.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Osc52.kt new file mode 100644 index 0000000000..3182f0b829 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Osc52.kt @@ -0,0 +1,147 @@ +package dev.nodeterm.protocol.model + +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.CodingErrorAction + +/** + * An OSC 52 clipboard write from the pane (tmux copy-mode with `set-clipboard on`, or a program + * such as vim `"+y`) turned into clipboard text. Mirrors the desktop's `parseOsc52` + * (src/renderer/terminal/osc52.ts): the payload is `;` and the `;` is + * required; it is WRITE-ONLY, so a `?` read query is refused (a remote program must never read the + * phone's clipboard); the base64 is decoded the way the desktop's `atob` decodes it (ASCII + * whitespace and missing padding tolerated, anything else outside the alphabet refused) and the + * bytes as strict UTF-8 (`TextDecoder` with `fatal: true`, including its removal of one leading + * byte-order mark). + * + * The size cap is the one place the phone must be STRICTER than the desktop (audit A53). The + * clipboard write is a binder transaction into the system server, whose buffer is about 1 MB and + * shared by every transaction in flight, and `ClipData` parcels its text as UTF-16. The desktop's + * cap of 1,000,000 base64 characters does not keep a copy under that: tmux 3.4 forwards a + * 986,675-character payload (measured), about 740 KB of text and about 1.5 MB as a parcel, over the + * binder limit; a failed transaction is rethrown from `setPrimaryClip` as a RuntimeException, which + * the app did not catch (not reproduced on a device). So there are two caps here: + * [MAX_TEXT_CHARS] is the real one, and [MAX_BASE64] is the most base64 such a text can take, + * checked first so an oversized payload is refused before anything is decoded. terminal.js reads + * [MAX_BASE64] over the bridge and applies it before the sequence crosses it. + */ +object Osc52 { + /** + * The longest selection field (the part before the `;`) accepted: xterm's own are `c`, `p`, + * `q`, `s` and `0`-`7`, possibly several together. A longer field is not a clipboard write we + * understand, and terminal.js drops it before the bridge so it cannot carry megabytes across. + */ + const val MAX_SELECTION = 16 + + /** + * The most text the phone hands the clipboard, in UTF-16 code units (a Kotlin `String`'s + * length). At two bytes a unit the parcel stays around 200 KB: a fifth of the shared binder + * buffer, and far more than a screen of copy-mode selection. + */ + const val MAX_TEXT_CHARS = 100_000 + + /** + * The most base64 that can still decode to a text within [MAX_TEXT_CHARS]. A UTF-16 unit takes + * at most three UTF-8 bytes (a surrogate pair is two units for four bytes), and base64 spends + * four characters per three bytes, so this cap never refuses a text the text cap would take. + * It is measured on the raw payload, before whitespace is removed, exactly as the desktop does. + */ + const val MAX_BASE64 = MAX_TEXT_CHARS * 4 + + sealed interface Result { + /** Put [text] on the clipboard. Never empty and never longer than [MAX_TEXT_CHARS]. */ + data class Copy(val text: String) : Result + + /** + * Nothing to copy, and nothing wrong: no `;` separator, an empty payload, a `?` read query, + * or a payload that decodes to no text. (An empty decode writes nothing, where the desktop + * writes an empty string: a "Copied" toast over an emptied clipboard would be a lie.) + */ + data object Ignored : Result + + /** The payload is not base64, or its bytes are not UTF-8. The desktop ignores these too. */ + data object Invalid : Result + + /** Over [MAX_BASE64] or [MAX_TEXT_CHARS]. The user asked for a copy and should be told. */ + data object TooLarge : Result + } + + fun parse(data: String): Result { + val i = data.indexOf(';') + if (i < 0 || i > MAX_SELECTION) return Result.Ignored + // Length first: xterm.js accepts OSC payloads up to 10,000,000 characters, and there is no + // reason to copy one out of `data` only to refuse it. + if (data.length - i - 1 > MAX_BASE64) return Result.TooLarge + val payload = data.substring(i + 1) + if (payload.isEmpty() || payload == "?") return Result.Ignored + val bytes = decodeBase64(payload) ?: return Result.Invalid + val text = decodeUtf8(bytes) ?: return Result.Invalid + if (text.isEmpty()) return Result.Ignored + if (text.length > MAX_TEXT_CHARS) return Result.TooLarge + return Result.Copy(text) + } + + /** + * The WHATWG forgiving-base64 decode, which is what `atob` runs: drop ASCII whitespace, drop at + * most two trailing `=` when the length is a multiple of four, refuse a length of 1 mod 4 and + * any character outside `A-Z a-z 0-9 + /`, and discard the leftover bits of a short last group. + * Null = not base64. (android.util.Base64, used before, silently SKIPS characters it does not + * know, so garbage decoded to something.) + */ + internal fun decodeBase64(s: String): ByteArray? { + val chars = CharArray(s.length) + var n = 0 + for (c in s) { + if (c == ' ' || c == '\t' || c == '\n' || c == '\u000C' || c == '\r') continue + chars[n++] = c + } + if (n % 4 == 0 && n > 0 && chars[n - 1] == '=') { + n-- + if (n > 0 && chars[n - 1] == '=') n-- + } + if (n % 4 == 1) return null + val out = ByteArray(n / 4 * 3 + maxOf(0, n % 4 - 1)) + var bits = 0 + var count = 0 + var o = 0 + for (k in 0 until n) { + val v = sextet(chars[k]) + if (v < 0) return null + bits = (bits shl 6) or v + count += 6 + if (count >= 8) { + count -= 8 + out[o++] = (bits shr count).toByte() + bits = bits and ((1 shl count) - 1) + } + } + return out + } + + private fun sextet(c: Char): Int = when (c) { + in 'A'..'Z' -> c - 'A' + in 'a'..'z' -> c - 'a' + 26 + in '0'..'9' -> c - '0' + 52 + '+' -> 62 + '/' -> 63 + else -> -1 + } + + /** + * Strict UTF-8: malformed input (bad or truncated sequences, overlong forms, encoded + * surrogates) is an error, never a replacement character. One leading U+FEFF is dropped, as + * `TextDecoder` drops it. Null = not UTF-8. + */ + internal fun decodeUtf8(bytes: ByteArray): String? { + val text = try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString() + } catch (_: CharacterCodingException) { + return null + } + return if (text.startsWith('')) text.substring(1) else text + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/PairedHost.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/PairedHost.kt new file mode 100644 index 0000000000..d9cdb99532 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/PairedHost.kt @@ -0,0 +1,154 @@ +package dev.nodeterm.protocol.model + +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.pairing.PairingResult +import dev.nodeterm.protocol.pairing.RelayBlock +import dev.nodeterm.protocol.ssh.HostKeyAnchors +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** + * One paired computer, as the phone keeps it. Public facts only — the phone's own secrets (its box + * key, its SSH seed) live in the app's Keystore-backed store, never in this record — except the + * relay device token, which is a bearer credential and is therefore persisted by the app through + * the same encrypted store (see the app's `SecureStore`). + * + * A computer added by its SSH address instead of a pairing code ([manual], audit A27) is one of + * these too: the same fields, no relay, and a host key pinned by the first connect that + * authenticated ([dev.nodeterm.protocol.ssh.ManualHost]). + */ +data class PairedHost( + /** Local id for this pairing (the host-assigned deviceId — unique per pairing). */ + val id: String, + val name: String, + val host: String, + val port: Int, + val user: String, + val sshAvailable: Boolean, + /** The host's box key from the QR (`hostKey`): the relay identity pinned at pairing. */ + val hostKeyB64: String?, + val relay: RelayBlock?, + /** + * `SHA256:…` of the SSH host key, pinned by the first connect that authenticated. When + * [sshHostKeyAnchors] is not empty that connect had to present one of them; otherwise it was trust + * on first use. For a host certificate, the key it certifies + * ([dev.nodeterm.protocol.ssh.SshHostConnection.hostKeyFingerprint]); an older build pinned the + * certificate itself, which the next connect to that server re-spells. + */ + val sshHostKeyFingerprint: String?, + /** When it was paired, or added by address. */ + val pairedAt: Long, + /** + * Added by its SSH address ("Add SSH server", audit A27), not paired: reached over SSH only. It + * has no relay leg, ever — a Server Edition has no pairing service and a plain SSH host no + * standing phone host — so the app fixes its route to SSH and never adopts a relay for it. + * Persisted as `"manual": true`, which a build that predates it ignores (it then sees a paired + * computer with SSH and no relay, which is what this is) — and drops: such a build rewrites the + * whole list on its next save with no `manual` key. So [fromJson] also reads the flag from the + * id, which survives that round trip ([MANUAL_ID_PREFIX]). + */ + val manual: Boolean = false, + /** + * The computer's SSH host key fingerprints from its sealed pairing answer (audit A49-anchor, + * [dev.nodeterm.protocol.ssh.HostKeyAnchors]): the first SSH connect must present one of them before + * anything is pinned. Empty for a computer whose desktop sent none (older, Windows, keys it could + * not read) and for one added by its SSH address; that first connect is trust on first use. Kept + * after the pin is set, which then decides on its own. Persisted as `sshHostKeyAnchors` only when + * there are some, so a record without them is what it was; a build that predates the key drops it + * on its next save, which leaves the computer on trust on first use, as that build always had it. + */ + val sshHostKeyAnchors: List = emptyList(), + /** Explicit remote Desktop/Server profile folder; null keeps automatic SSH discovery. */ + val sshProfilePath: String? = null +) { + /** The relay host key: the relay block's when present, else the QR's `hostKey` (same key). */ + val relayHostKeyB64: String? get() = relay?.hostPublicKeyB64 ?: hostKeyB64 + + /** + * How the SSH leg is named on screen. A paired computer's is the LAN one the pairing found; a + * computer added by address may be reached any way its SSH is (a VPN, a public address), so it is + * not called "your network". + */ + val sshLegName: String get() = if (manual) "Over SSH" else "On your network" + + fun toJson(): JsonObject = buildJsonObject { + put("id", id) + put("name", name) + put("host", host) + put("port", port) + put("user", user) + put("sshAvailable", sshAvailable) + hostKeyB64?.let { put("hostKeyB64", it) } + relay?.let { r -> + put("relay", buildJsonObject { + put("hostId", r.hostId) + put("hostPublicKeyB64", r.hostPublicKeyB64) + put("relayEndpoint", r.relayEndpoint) + }) + } + sshHostKeyFingerprint?.let { put("sshHostKeyFingerprint", it) } + if (sshHostKeyAnchors.isNotEmpty()) put("sshHostKeyAnchors", JsonArray(sshHostKeyAnchors.map(::JsonPrimitive))) + put("pairedAt", pairedAt) + // Only when set: a paired computer's record is byte-for-byte what it was before A27. + if (manual) put("manual", true) + sshProfilePath?.let { put("sshProfilePath", it) } + } + + companion object { + /** + * The id prefix of a computer added by its SSH address ([dev.nodeterm.protocol.ssh.ManualHost.newId]). + * A paired computer's id is the desktop's `randomUUID()` (pairing-service.ts), which never + * starts with it. + */ + const val MANUAL_ID_PREFIX = "ssh-" + + fun fromJson(o: JsonObject): PairedHost? { + val id = o.s("id") ?: return null + // A malformed explicit choice must never silently select another profile. + if ("sshProfilePath" in o && o.s("sshProfilePath") == null) return null + // The key, or the id when a build that predates the key saved the record without it: going + // back to such a build and forward again must not turn this computer into a paired one, + // with route choices, a relay adoption and "turn on remote access" advice (review of A27b). + val manual = o.b("manual") == true || id.startsWith(MANUAL_ID_PREFIX) + return PairedHost( + id = id, + name = o.s("name") ?: "Computer", + host = o.s("host") ?: return null, + port = o.l("port")?.toInt() ?: 22, + user = o.s("user") ?: return null, + // A computer added by address is reached over SSH or not at all. + sshAvailable = manual || o.b("sshAvailable") != false, + // No relay leg for one added by address, whatever the record says: nothing pairs it. + hostKeyB64 = if (manual) null else o.s("hostKeyB64"), + relay = if (manual) null else o.o("relay")?.let(PairingPayload::parseRelayBlock), + sshHostKeyFingerprint = o.s("sshHostKeyFingerprint"), + pairedAt = o.l("pairedAt") ?: 0, + manual = manual, + // Nothing pairs a computer added by address, so nothing anchors its first connect. + sshHostKeyAnchors = if (manual) emptyList() else HostKeyAnchors.parse(o["sshHostKeyAnchors"]), + sshProfilePath = o.s("sshProfilePath") + ) + } + + fun from(payload: PairingPayload, result: PairingResult, now: Long = System.currentTimeMillis()) = PairedHost( + id = result.deviceId, + name = payload.name, + host = payload.host, + port = payload.port, + user = payload.user, + sshAvailable = payload.sshAvailable, + hostKeyB64 = payload.hostKey, + relay = result.relay ?: payload.relay, + sshHostKeyFingerprint = null, + pairedAt = now, + sshHostKeyAnchors = result.sshHostKeyFingerprints + ) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/PairedHostReplacement.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/PairedHostReplacement.kt new file mode 100644 index 0000000000..1d9ade20c1 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/PairedHostReplacement.kt @@ -0,0 +1,30 @@ +package dev.nodeterm.protocol.model + +/** Phone-local record replacement; old pairing preferences never authorize the new pairing. */ +object PairedHostReplacement { + data class Plan( + val hosts: List, + val replaced: List, + val retiredPreferenceKeys: List, + ) + + fun plan(current: List, incoming: PairedHost): Plan { + // Preserve the existing endpoint/name replacement policy. An update of the retained id + // keeps its route, relay approval and creation checkpoints, even while retiring siblings. + val replaced = current.filter { + it.id != incoming.id && it.host == incoming.host && it.user == incoming.user && it.name == incoming.name + } + return Plan( + hosts = current.filterNot { it.id == incoming.id || it in replaced } + incoming, + replaced = replaced, + retiredPreferenceKeys = replaced.flatMap { + listOf("route.${it.id}", "relayApproved.${it.id}", "managedCreation.${it.id}") + }.distinct(), + ) + } + + /** Exact prior-key and endpoint/name records whose connections must refresh on re-pair. */ + fun retired(current: List, incoming: PairedHost, previous: PairedHost?): List = + (listOfNotNull(previous) + plan(current, incoming).replaced).distinctBy { it.id } + +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Pane.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Pane.kt new file mode 100644 index 0000000000..08434472e5 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Pane.kt @@ -0,0 +1,21 @@ +package dev.nodeterm.protocol.model + +/** + * What a tmux pane's foreground command (`#{pane_current_command}`) says about a session: the + * desktop's `isShellCommand` (src/shared/agents/pane.ts), the gate its own wake keeps before it types + * a resume line. True means a SHELL owns the pane, so the agent CLI has let go of it. + * + * The same seven names, and deliberately not "anything but a known agent": an npm-installed CLI + * reports `node`, a native one its own name, and a shell the list does not know (nu, pwsh, xonsh) + * reads as "not a shell". For the question asked here (may a launch line be typed into this pane?), + * an unknown answers no. A login shell reports as `-zsh`, and tmux may report a full path. + */ +object Pane { + /** `SHELLS` in src/shared/agents/pane.ts (pinned against that file by a test). */ + val SHELLS: Set = setOf("zsh", "bash", "sh", "fish", "dash", "ksh", "tcsh") + + fun isShell(command: String?): Boolean { + if (command.isNullOrEmpty()) return false + return command.removePrefix("-").substringAfterLast('/') in SHELLS + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt new file mode 100644 index 0000000000..2aa0a1e552 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt @@ -0,0 +1,242 @@ +package dev.nodeterm.protocol.model + +import dev.nodeterm.protocol.model.J.a +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.d +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.objects +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.model.J.strings +import kotlinx.serialization.json.JsonObject + +/** + * Parses the `projects.list` blob: `\n--NT-PROJECTS-SPLIT--\n\n--NT-STATUS-SPLIT--\n` (`buildProjectsListBlob` in + * src/core/projects-list-blob.ts, which the desktop's `listProjectsOutput` serves). The + * direct-SSH browse produces the same shape, so both transports share this parser. Every section + * is best-effort: a missing or corrupt one degrades to empty, never to an exception. + */ +object ProjectsParser { + const val PROJECTS_MARK = "--NT-PROJECTS-SPLIT--" + const val STATUS_MARK = "--NT-STATUS-SPLIT--" + + fun parseBlob(blob: String, now: Long = System.currentTimeMillis()): ProjectsSnapshot { + val p = blob.indexOf(PROJECTS_MARK) + val s = blob.indexOf(STATUS_MARK) + val workspaceText = if (p >= 0) blob.substring(0, p) else blob + val sessionsText = if (p >= 0) blob.substring(p + PROJECTS_MARK.length, if (s > p) s else blob.length) else "" + val statusText = if (s >= 0) blob.substring(s + STATUS_MARK.length) else "" + return ProjectsSnapshot( + projects = parseWorkspace(workspaceText), + liveSessions = sessionsText.lineSequence().map { it.trim() }.filter { it.startsWith("nt-") }.toSet(), + status = parseStatus(statusText), + fetchedAt = now + ) + } + + /** The assembled v2 workspace `{version:2, projects:[…]}`. A v3 index carries no nodes and is + * resolved by the SSH browse before it gets here. */ + fun parseWorkspace(text: String): List { + val root = J.obj(J.parse(text)) ?: return emptyList() + return root.objects("projects").mapNotNull(::parseProject) + } + + fun parseProject(p: JsonObject): ProjectInfo? { + val id = p.s("id") ?: return null + val ssh = p.o("ssh")?.o("server") + val sshTarget = ssh?.let { srv -> + val host = srv.s("host") ?: return@let null + val user = srv.s("user") + if (user.isNullOrEmpty()) host else "$user@$host" + } + return ProjectInfo( + id = id, + name = p.s("name") ?: id, + color = p.s("color"), + cwd = p.s("cwd"), + sshTarget = sshTarget, + closed = p.b("closed") == true, + nodes = p.objects("nodes").mapNotNull(::parseNode), + board = p.o("kanban")?.let(::parseBoard), + defaultPermissionMode = p.s("defaultPermissionMode"), + defaultAccountId = p.s("defaultAccountId") + ) + } + + fun parseNode(n: JsonObject): NodeInfo? { + val id = n.s("id") ?: return null + val icon = n.o("icon") + val emoji = if (icon?.s("type") == "emoji") icon.s("value")?.takeIf { it.isNotBlank() && it.length <= 16 } else null + return NodeInfo( + id = id, + kind = NodeKind.of(n.s("kind")), + title = n.s("title") ?: "", + color = n.s("color"), + agentId = n.s("agentId") ?: legacyAgent(n), + accountId = n.s("accountId"), + cwd = n.s("cwd"), + parentId = n.s("parentId") ?: n.s("group"), + iconEmoji = emoji, + agentSessionId = n.s("agentSessionId"), + text = n.s("text") + ) + } + + /** `nodeStatesToFlow` migrates the legacy `tags:['claude']` marker to `agentId = 'claude'`. */ + private fun legacyAgent(n: JsonObject): String? = if ("claude" in n.strings("tags")) "claude" else null + + fun parseBoard(k: JsonObject): KanbanBoard? { + val columns = k.objects("columns").mapNotNull { c -> + val id = c.s("id") ?: return@mapNotNull null + KanbanColumn(id, c.s("title") ?: "", c.s("color")) + } + // `validKanban` drops a malformed board to the fresh default; the phone does the same by + // reporting none, so the UI shows the default and a write seeds a real one. + if (columns.isEmpty() && k["columns"] == null) return null + val assignments = k.objects("assignments").mapNotNull { a -> + val nodeId = a.s("nodeId") ?: return@mapNotNull null + val columnId = a.s("columnId") ?: return@mapNotNull null + nodeId to columnId + } + val labels = k.objects("labels").mapNotNull { l -> + val id = l.s("id") ?: return@mapNotNull null + KanbanLabel(id, l.s("name") ?: "", l.s("color") ?: "default") + } + val meta = k.objects("meta").mapNotNull { m -> + val nodeId = m.s("nodeId") ?: return@mapNotNull null + KanbanCardMeta( + nodeId = nodeId, + assignees = m.objects("assignees").mapNotNull { it.s("name") }, + dueAt = m.l("dueAt"), + priority = m.s("priority"), + labels = m.strings("labels") + ) + } + return KanbanBoard(columns, assignments, labels, meta) + } + + fun parseStatus(text: String): AgentStatusFile? { + val root = J.obj(J.parse(text)) ?: return null + val nodes = LinkedHashMap() + root.o("nodes")?.forEach { (nodeId, raw) -> + val e = J.obj(raw) ?: return@forEach + nodes[nodeId] = AgentNodeStatus( + state = AgentState.of(e.s("state")), + agentId = e.s("agentId"), + sessionId = e.s("sessionId"), + name = e.s("name"), + hibernated = e.b("hibernated") == true, + updatedAt = e.l("updatedAt") ?: 0, + account = e.o("account")?.let(::parseObservedAccount) + ) + } + return AgentStatusFile( + updatedAt = root.l("updatedAt") ?: 0, + nodes = nodes, + settings = root.o("settings")?.let(::parseSettings), + usage = root.o("usage")?.let(::parseUsage), + inbox = root.o("inbox")?.let(::parseInbox), + server = root.o("server")?.let(::parseServer) + ) + } + + /** + * The `server` block (`MirrorServer`). Strings from a file on the host, shown on the phone: each + * is kept only when it is short, printable text, so a hand-edited install-meta.json cannot put a + * screenful (or control characters) into the host screen. + */ + private fun parseServer(s: JsonObject): MirrorServer? { + fun field(key: String) = s.s(key)?.trim()?.takeIf { it.isNotEmpty() && it.length <= 64 && it.none { c -> c.isISOControl() } } + return MirrorServer(field("version"), field("commit"), field("installedAt")) + .takeIf { it.version != null || it.commit != null || it.installedAt != null } + } + + /** `ObservedClaudeAccount`. A wrong-typed `known` reads as false: the entry then names its dir, + * which is the label that claims least. */ + private fun parseObservedAccount(a: JsonObject) = ObservedAccount( + configDir = a.s("configDir"), + accountId = a.s("accountId"), + known = a.b("known") == true + ) + + private fun parseSettings(s: JsonObject) = MirrorSettings( + claudePermissionMode = s.s("claudePermissionMode"), + autoSupported = s.b("autoSupported"), + claudeAccounts = s.objects("claudeAccounts").mapNotNull { a -> + val id = a.s("id") ?: return@mapNotNull null + val dir = a.s("dir") ?: return@mapNotNull null + ManagedAccount(id, dir, label = a.s("label"), email = a.s("email")) + }, + codexApprovalValues = s.strings("codexApprovalValues") + ) + + private fun parseUsage(u: JsonObject) = MirrorUsage( + updatedAt = u.l("updatedAt") ?: 0, + accounts = u.objects("accounts").map { a -> + UsageAccount( + accountId = a.s("accountId"), + label = a.s("label"), + email = a.s("email"), + agentId = a.s("agentId") ?: "claude", + status = a.s("status") ?: "unavailable", + updatedAt = a.l("updatedAt") ?: 0, + limits = a.objects("limits").mapNotNull { l -> + val kind = l.s("kind") ?: return@mapNotNull null + val used = l.d("usedPercent") ?: return@mapNotNull null + UsageLimit( + kind = kind, + group = l.s("group"), + usedPercent = used, + severity = l.s("severity"), + resetsAt = l.l("resetsAt"), + windowMinutes = l.l("windowMinutes"), + scopeLabel = l.s("scopeLabel"), + isActive = l.b("isActive") == true + ) + } + ) + } + ) + + private fun parseInbox(i: JsonObject): MirrorInbox { + val events = i.objects("events").mapNotNull { e -> + val id = e.s("id") ?: return@mapNotNull null + val nodeId = e.s("nodeId") ?: return@mapNotNull null + val kind = InboxKind.of(e.s("kind")) ?: return@mapNotNull null + InboxEvent( + id = id, + ts = e.l("ts") ?: 0, + nodeId = nodeId, + agentId = e.s("agentId"), + sessionId = e.s("sessionId"), + kind = kind, + title = e.s("title") ?: "", + detail = e.s("detail"), + interrupted = e.b("interrupted") == true, + resolved = e.b("resolved") == true, + options = e.strings("options"), + multiSelect = e.b("multiSelect") == true, + pendingId = e.s("pendingId"), + permissionSuggestions = HookReplies.permissionSuggestions(e["permissionSuggestions"]), + questionPendingId = e.s("questionPendingId"), + questions = HookReplies.questions(e["questions"]) ?: emptyList() + ) + } + val nodes = LinkedHashMap() + i.o("nodes")?.forEach { (nodeId, raw) -> + val n = J.obj(raw) ?: return@forEach + nodes[nodeId] = InboxNodeNow( + activity = n.s("activity"), + tool = n.s("tool"), + contextPercent = n.d("contextPercent"), + prompt = n.s("prompt"), + updatedAt = n.l("updatedAt") ?: 0 + ) + } + return MirrorInbox(events, nodes) + } + + @Suppress("unused") + private fun JsonObject.hasArray(key: String): Boolean = a(key).isNotEmpty() +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/QuestionChoices.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/QuestionChoices.kt new file mode 100644 index 0000000000..fcdc3738f1 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/QuestionChoices.kt @@ -0,0 +1,37 @@ +package dev.nodeterm.protocol.model + +/** + * What an Inbox question card shows under the question (audit A57), and the one rule + * [dev.nodeterm.protocol.host.QuickActions.answerQuestion] answers by, so the card never offers an + * answer the action would refuse. + * + * Held v2 questions expose the complete schema and explicit selections to the hook. A malformed + * held schema never falls back to digits. Legacy unheld single-select prompts retain numbered + * actions; legacy multi-select prompts remain read-only because their terminal keys are unmeasured. + */ +sealed interface QuestionChoices { + /** A live held v2 picker: explicit selections for every question, never terminal digits. */ + data class Held(val ticket: String, val questions: List) : QuestionChoices + /** Each row is a quick answer: tapping row i types the digit i + 1 (`QuickActions.answerQuestion`). */ + data class Answer(val rows: List) : QuestionChoices + + /** The picker takes several options: the rows are shown under [SEVERAL_NOTE] and never answered. */ + data class ReadOnly(val rows: List) : QuestionChoices + + /** No options to show: an approval, a finished turn, or a question the desktop sent without a picker. */ + data object None : QuestionChoices + + companion object { + /** Marks a [ReadOnly] card: its rows are not buttons, and the answer is given in the session. */ + const val SEVERAL_NOTE = "Choose several — answer in the session." + + fun of(event: InboxEvent): QuestionChoices { + if (event.kind == InboxKind.QUESTION && event.questionPendingId != null) + return if (event.questions.isNotEmpty()) Held(event.questionPendingId, event.questions) else None + if (event.kind != InboxKind.QUESTION || event.options.isEmpty()) return None + // Numbered in the order the question lists them (the desktop keeps that order). + val rows = event.options.mapIndexed { i, option -> "${i + 1}. $option" } + return if (event.multiSelect) ReadOnly(rows) else Answer(rows) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt new file mode 100644 index 0000000000..962573d9a3 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SeenLog.kt @@ -0,0 +1,334 @@ +package dev.nodeterm.protocol.model + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Which Inbox events this phone has already announced, read or had on screen (phone-local, like + * iOS), so each event raises at most one notification (audit A48), whichever check sees it first. + * + * KEYED BY COMPUTER. An event id is the desktop's `${ts}-${seq}`, and `seq` is a per-run counter + * of that desktop's mirror (src/core/agent-status-mirror.ts), so two computers can mint the same id + * in the same millisecond. With one phone-wide set, the first computer's event would silently + * swallow the second one's notification. So every entry belongs to the pairing id + * ([PairedHost.id]) of the computer whose listing it came from, and forgetting a computer + * ([forgetHost]) drops its entries; a computer paired again under a new id keeps them ([moveHost]), + * since its event ids continue. + * + * ONE EVENT, TWO PAIRINGS. The same event can reach the phone through two pairings: a desktop's + * listing carries its whole mirror, the nodes of its SSH projects included, and the SSH host it + * drives lists the slice the desktop pushes there (audit A27), with the desktop's events and ids + * unchanged. So an entry also keeps the event's node, and an event counts as seen under its own + * computer, or wherever another pairing recorded the same id FOR THE SAME NODE ([Entries.seen]); + * meeting it that way records it under this computer too, so forgetting the other pairing later does + * not announce it again. A real collision is two computers minting the same id for DIFFERENT nodes, + * which stays apart. The same id for the same node on two computers needs the same node id on both + * (a canvas committed to two repositories) and the same millisecond and counter value, which is + * treated as never happening. + * + * The first version kept a bare id set and trimmed it with `(set + ids).toList().takeLast(500)`. + * The set came back from SharedPreferences as a `HashSet`, whose order is hash order, so past 500 + * entries every new id evicted an ARBITRARY old one — and an evicted id whose event was still + * unresolved and inside the announce window was announced again. Its read-modify-write was also + * unlocked while the worker, the terminal screen and the Inbox tab all called it. + * + * What is kept instead: id → an ANCHOR time, the latest of when the phone saw the event and the + * event's own `ts`. An entry is pruned only when its anchor is more than [RETENTION_MS] old — the + * announce window plus a margin — and an event stops being announceable [ANNOUNCE_WINDOW_MS] after + * its `ts` on the same (phone) clock. Since the anchor is never earlier than `ts`, an entry cannot + * be pruned while its event could still be announced, whatever the desktop's clock says; the `ts` + * is in the anchor precisely for a desktop clock running ahead of the phone's. So nothing is + * evicted by COUNT on the way to a notification: [maxEntries] is only a memory backstop (for a + * desktop that keeps minting future-dated events), sized far beyond what a real Inbox reaches — + * the desktop caps its feed at 50 events — and it drops the OLDEST anchors first, across computers. + * + * Older formats migrate on first use and announce nothing again. Neither says which computer an + * entry came from, so their entries become [Entries.shared]: seen for EVERY computer until they age + * out, one retention period after their anchor (the pre-A48 set is migrated as "seen now"). Nothing + * new is ever written there. Attributing them to a computer is not possible, and dropping them would + * announce again everything the user already saw; the cost of sharing is the old phone-wide + * behaviour for those ids alone, for at most a day after the upgrade. [forgetHost] cannot drop the + * shared ones either, which is harmless for the same reason. + * + * Every entry point holds this object's lock across the whole read-modify-write, and + * [claimAnnounceable] and [claimLive] (the app's path since A73) decide and record in one step, so + * two callers cannot both announce an event. + */ +class SeenLog( + private val storage: Storage, + private val clock: () -> Long = System::currentTimeMillis, + private val maxEntries: Int = MAX_ENTRIES +) { + /** Where the log is kept (one string in the app's preferences on Android). */ + interface Storage { + /** The log as [encode] wrote it, or null when it has never been written. */ + fun read(): String? + + /** The A48 format, one id → anchor map for every computer ([decodeIds]). Null when there is none. */ + fun readV2(): String? + + /** The pre-A48 format: a bare set of ids with no times. Null when there is none. */ + fun readLegacy(): Set? + + /** Store [encoded] and drop both older formats, in one edit. */ + fun write(encoded: String) + } + + /** + * One event recorded under a computer: its [anchor], and the node it belongs to. [nodeId] is null + * for an entry written before nodes were kept, which then counts under its own computer only. + */ + data class Seen(val anchor: Long, val nodeId: String?) + + /** + * What is stored: each computer's entries (event id → [Seen]) under its pairing id, and the + * [shared] entries (event id → anchor) an older format left, which count for every computer until + * they age out. + */ + class Entries( + val hosts: LinkedHashMap> = LinkedHashMap(), + val shared: LinkedHashMap = LinkedHashMap() + ) { + /** Whether [id] is recorded for the computer [hostId] itself, or left by an older format. */ + fun has(hostId: String, id: String): Boolean = hosts[hostId]?.containsKey(id) == true || id in shared + + /** Whether another pairing recorded [ev]: the same id for the same node (see the class comment). */ + fun elsewhere(hostId: String, ev: InboxEvent): Boolean = + hosts.any { (other, ids) -> other != hostId && ids[ev.id]?.nodeId == ev.nodeId } + + /** Whether [ev] counts as seen for the computer [hostId]. */ + fun seen(hostId: String, ev: InboxEvent): Boolean = has(hostId, ev.id) || elsewhere(hostId, ev) + + /** The ids recorded under [hostId] alone (not the shared ones). */ + fun ids(hostId: String): Set = hosts[hostId]?.keys.orEmpty() + + /** The anchors recorded under [hostId] alone, by id. */ + fun anchors(hostId: String): Map? = hosts[hostId]?.mapValues { it.value.anchor } + + val size: Int get() = shared.size + hosts.values.sumOf { it.size } + } + + /** Whether [event] is recorded as announced or read for the computer [hostId] ([Entries.seen]). */ + @Synchronized + fun isSeen(hostId: String, event: InboxEvent): Boolean = load(clock()).seen(hostId, event) + + /** The phone has seen [events] of the computer [hostId] (the user read them, or they were announced). */ + @Synchronized + fun markSeen(hostId: String, events: Collection) { + if (events.isEmpty()) return + val now = clock() + val entries = load(now) + for (ev in events) record(entries, hostId, ev, now) + save(entries, now) + } + + /** + * One check of a fresh listing ([events], the feed of the computer [hostId]): the events to + * notify about now, recorded as announced in the same locked step (audit A73). What [onScreen] + * shows is recorded as seen and never returned, so no later check (the next refresh, or the + * background one) announces what the user already looked at; that happens even when [notify] is + * false (notifications off, or not allowed by the system), so turning them on later does not + * announce it either. What waits on a terminal still connecting ([OnScreen.Split.waiting]) is + * left untouched, for a later listing to decide. The rest is [claimAnnounceable]'s answer when + * [notify], and left untouched otherwise. + * + * Only events that could still be announced are recorded, an entry already recorded is left as it + * is, and nothing is written when nothing is new: the live check runs every 8 s. Leaving an + * anchor alone is safe, since the first anchor is never earlier than the event's `ts`, so the + * entry outlives the announce window. An event another pairing recorded ([Entries.elsewhere]) is + * not announced, and is recorded under [hostId] once. With [OnScreen.NOTHING] and [notify] it + * returns what [claimAnnounceable] returns. + */ + @Synchronized + fun claimLive(hostId: String, events: List, onScreen: OnScreen, notify: Boolean): List { + val now = clock() + val (shown, offScreen) = onScreen.split(events.filter { announceable(it, now) }) + val claim = if (notify) offScreen else emptyList() + if (shown.isEmpty() && claim.isEmpty()) return emptyList() + val entries = load(now) + var changed = false + for (ev in shown) { + if (entries.has(hostId, ev.id)) continue + record(entries, hostId, ev, now) + changed = true + } + val fresh = ArrayList() + for (ev in claim) { + if (entries.has(hostId, ev.id)) continue + if (!entries.elsewhere(hostId, ev)) fresh += ev + record(entries, hostId, ev, now) + changed = true + } + if (changed) save(entries, now) + return fresh + } + + /** + * The events of [events] (the feed of the computer [hostId], oldest → newest) that should raise + * a notification now — unresolved, inside the announce window and never announced or read, here + * or through another pairing ([Entries.seen]) — recorded as announced in the same step. Order is + * kept. One another pairing recorded is recorded under [hostId] too, and not returned. + */ + @Synchronized + fun claimAnnounceable(hostId: String, events: List): List { + val now = clock() + val entries = load(now) + val fresh = ArrayList() + var changed = false + for (ev in events) { + if (!announceable(ev, now) || entries.has(hostId, ev.id)) continue + if (!entries.elsewhere(hostId, ev)) fresh += ev + record(entries, hostId, ev, now) + changed = true + } + if (changed) save(entries, now) + return fresh + } + + /** The phone forgot the computer [hostId]: its entries go with it (the shared ones cannot). */ + @Synchronized + fun forgetHost(hostId: String) { + val now = clock() + val entries = load(now) + if (entries.hosts.remove(hostId) != null) save(entries, now) + } + + /** + * The computer once paired as [from] is now paired as [to] (paired again, which mints a new + * pairing id): what the phone saw of it carries over, since its event ids continue. Where both + * hold an id, the later anchor wins (and a node either one knew is kept). + */ + @Synchronized + fun moveHost(from: String, to: String) { + if (from == to) return + val now = clock() + val entries = load(now) + val moved = entries.hosts.remove(from) ?: return + val target = entries.hosts.getOrPut(to) { LinkedHashMap() } + for ((id, seen) in moved) { + val had = target[id] + target[id] = if (had == null) seen else Seen(maxOf(had.anchor, seen.anchor), had.nodeId ?: seen.nodeId) + } + save(entries, now) + } + + private fun record(entries: Entries, hostId: String, ev: InboxEvent, now: Long) { + val mine = entries.hosts.getOrPut(hostId) { LinkedHashMap() } + mine[ev.id] = Seen(maxOf(mine[ev.id]?.anchor ?: Long.MIN_VALUE, now, ev.ts), ev.nodeId) + } + + /** The stored entries; migrates an older format (and persists that) the first time. */ + private fun load(now: Long): Entries { + storage.read()?.let { return decode(it, now) } + val shared = storage.readV2()?.let { decodeIds(it, now) } + ?: storage.readLegacy()?.associateWithTo(LinkedHashMap()) { now } + ?: return Entries() + val migrated = Entries(shared = shared) + save(migrated, now) + return migrated + } + + private fun save(entries: Entries, now: Long) { + storage.write(encode(prune(entries, now, maxEntries))) + } + + companion object { + /** The background check announces only events younger than this (by the phone's clock). */ + const val ANNOUNCE_WINDOW_MS: Long = 6 * 3_600_000L + + /** How long past the window an entry is kept: slack for a desktop clock that runs ahead. */ + const val RETENTION_MARGIN_MS: Long = 18 * 3_600_000L + + /** An entry whose anchor is older than this is dropped. */ + const val RETENTION_MS: Long = ANNOUNCE_WINDOW_MS + RETENTION_MARGIN_MS + + /** Memory backstop only (see the class comment); never reached by a real Inbox. */ + const val MAX_ENTRIES: Int = 5_000 + + /** Unresolved and inside the announce window. Written without `now - ts`, which overflows. */ + fun announceable(ev: InboxEvent, now: Long): Boolean = !ev.resolved && ev.ts > now - ANNOUNCE_WINDOW_MS + + /** + * `{"hosts":{"":{"":{"t":anchor,"n":""}}},"shared":{"":anchor}}`; + * empty parts, and an unknown node, are left out. + */ + fun encode(entries: Entries): String { + val parts = LinkedHashMap() + val hosts = entries.hosts.filterValues { it.isNotEmpty() } + if (hosts.isNotEmpty()) parts["hosts"] = JsonObject(hosts.mapValues { (_, ids) -> JsonObject(ids.mapValues { encodeSeen(it.value) }) }) + if (entries.shared.isNotEmpty()) parts["shared"] = encodeIds(entries.shared) + return JsonObject(parts).toString() + } + + private fun encodeSeen(seen: Seen): JsonObject = JsonObject( + buildMap { + put("t", JsonPrimitive(seen.anchor)) + seen.nodeId?.let { put("n", JsonPrimitive(it)) } + } + ) + + /** + * Tolerant: an unreadable log, or an unreadable part of it, is empty; an entry whose time is + * unreadable counts as seen [now] — an id we once recorded stays recorded rather than being + * announced again — and one whose node is unreadable has none. A bare time, as the build + * before nodes were kept wrote a computer's entries, is an entry with no node. + */ + fun decode(raw: String, now: Long): Entries { + val obj = J.obj(J.parse(raw)) ?: return Entries() + val hosts = LinkedHashMap>() + J.obj(obj["hosts"])?.forEach { (hostId, ids) -> J.obj(ids)?.let { hosts[hostId] = seenOf(it, now) } } + val shared = J.obj(obj["shared"])?.let { idsOf(it, now) } ?: LinkedHashMap() + return Entries(hosts, shared) + } + + private fun seenOf(obj: JsonObject, now: Long): LinkedHashMap { + val out = LinkedHashMap() + for ((id, v) in obj) { + val entry = J.obj(v) + out[id] = if (entry == null) Seen(J.long(v) ?: now, null) else Seen(J.long(entry["t"]) ?: now, J.str(entry["n"])) + } + return out + } + + /** One id → anchor map: the A48 format, and this one's [Entries.shared] part. */ + fun encodeIds(ids: Map): JsonObject = JsonObject(ids.mapValues { JsonPrimitive(it.value) }) + + /** [decode]'s rules for one id → anchor map: the A48 format, read once to migrate it. */ + fun decodeIds(raw: String, now: Long): LinkedHashMap = + J.obj(J.parse(raw))?.let { idsOf(it, now) } ?: LinkedHashMap() + + private fun idsOf(obj: JsonObject, now: Long): LinkedHashMap { + val out = LinkedHashMap() + for ((id, v) in obj) out[id] = J.long(v) ?: now + return out + } + + /** + * Drops entries past [RETENTION_MS], and computers left with none; above [maxEntries] in all, + * keeps the newest anchors, whichever computer they belong to. + */ + fun prune(entries: Entries, now: Long, maxEntries: Int = MAX_ENTRIES): Entries { + val cutoff = now - RETENTION_MS + val hosts = LinkedHashMap>() + for ((hostId, ids) in entries.hosts) { + val kept = ids.filterTo(LinkedHashMap()) { it.value.anchor >= cutoff } + if (kept.isNotEmpty()) hosts[hostId] = kept + } + val shared = entries.shared.filterTo(LinkedHashMap()) { it.value >= cutoff } + val kept = Entries(hosts, shared) + if (kept.size <= maxEntries) return kept + // (computer or null for shared, id, anchor), newest first. + val newest = buildList { + for ((hostId, ids) in hosts) for ((id, s) in ids) add(Triple(hostId, id, s.anchor)) + for ((id, a) in shared) add(Triple(null, id, a)) + }.sortedByDescending { it.third }.take(maxEntries).mapTo(HashSet()) { it.first to it.second } + val trimmedHosts = LinkedHashMap>() + for ((hostId, ids) in hosts) { + val left = ids.filterTo(LinkedHashMap()) { (hostId to it.key) in newest } + if (left.isNotEmpty()) trimmedHosts[hostId] = left + } + return Entries(trimmedHosts, shared.filterTo(LinkedHashMap()) { (null to it.key) in newest }) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SessionSearch.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SessionSearch.kt new file mode 100644 index 0000000000..f21970c979 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SessionSearch.kt @@ -0,0 +1,44 @@ +package dev.nodeterm.protocol.model + +/** Local filtering of the sessions list; a query never changes the host's listing or node ids. */ +object SessionSearch { + enum class EmptyState { LOADING, EMPTY, NO_MATCHES } + + /** The same title displayed by a session row and used when opening its terminal. */ + fun title(node: NodeInfo, snapshot: ProjectsSnapshot): String = + snapshot.statusOf(node.id)?.name?.takeIf { it.isNotBlank() } + ?: node.title.takeIf { it.isNotBlank() } + ?: Agent.of(node.agentId)?.label + ?: "Terminal" + + /** The row's agent detail, which can also come from a hook when the node has no agent id. */ + fun agentLabel(node: NodeInfo, snapshot: ProjectsSnapshot): String = + Agent.of(node.agentId ?: snapshot.statusOf(node.id)?.agentId)?.label ?: "Terminal" + + /** + * Trimmed, case-insensitive literal matching, keeping project and node order. A project name or + * folder match keeps all its sessions; a session match keeps only that row. The node's folder + * is included too: phone-created shells have distinct folders under one synthetic project. + * A blank query returns the original open, nonempty projects unchanged. + */ + fun projects(snapshot: ProjectsSnapshot, query: String): List { + val needle = query.trim() + val available = snapshot.openProjects().filter { it.sessions.isNotEmpty() } + if (needle.isEmpty()) return available + fun String?.matches(): Boolean = this?.contains(needle, ignoreCase = true) == true + return available.mapNotNull { project -> + if (project.name.matches() || project.cwd.matches()) return@mapNotNull project + val matching = project.sessions.filter { node -> + title(node, snapshot).matches() || agentLabel(node, snapshot).matches() || node.cwd.matches() + } + if (matching.isEmpty()) null else project.copy(nodes = matching) + } + } + + /** Called when the filtered list is empty, so a search miss does not claim the host is empty. */ + fun emptyState(snapshot: ProjectsSnapshot, query: String): EmptyState = when { + query.isNotBlank() && snapshot.openProjects().any { it.sessions.isNotEmpty() } -> EmptyState.NO_MATCHES + snapshot.fetchedAt == 0L -> EmptyState.LOADING + else -> EmptyState.EMPTY + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SshTerminalCreation.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SshTerminalCreation.kt new file mode 100644 index 0000000000..6586df1087 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/SshTerminalCreation.kt @@ -0,0 +1,85 @@ +package dev.nodeterm.protocol.model + +import dev.nodeterm.protocol.ssh.PhoneTerminals +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.UUID + +/** A host-owned creation survives its dialog; a presentation ticket never survives dismissal. */ +class SshTerminalCreation( + private val scope: CoroutineScope, + private val attempt: suspend (Request) -> Unit, + private val definitiveFailure: (Exception) -> Boolean = { it is IllegalArgumentException }, + private val newId: () -> String = { "phone-${UUID.randomUUID()}" }, +) { + data class Request(val nodeId: String, val cwd: String?) + sealed interface State { + data object Idle : State + data class Creating(val request: Request) : State + data class Failed(val request: Request, val message: String, val canChangeFolder: Boolean) : State + data class Ready(val request: Request) : State + } + + private val lock = Any() + private val mutableState = MutableStateFlow(State.Idle) + val state: StateFlow = mutableState.asStateFlow() + private var nextTicket = 0L + private var presentation: Long? = null + + fun show(): Long = synchronized(lock) { (++nextTicket).also { presentation = it } } + fun hide(ticket: Long) = synchronized(lock) { if (presentation == ticket) presentation = null } + fun isCurrent(ticket: Long): Boolean = synchronized(lock) { presentation == ticket } + + /** Admission is synchronous: rapid taps cannot launch two jobs, even on a background scope. */ + fun submit(ticket: Long, cwd: String?, onCreated: suspend (Request) -> Unit): Boolean { + val request = synchronized(lock) { + if (presentation != ticket || mutableState.value is State.Creating || mutableState.value is State.Ready) return false + if (cwd != null && !PhoneTerminals.validCwd(cwd)) return false + val failed = mutableState.value as? State.Failed + if (failed != null && !failed.canChangeFolder && failed.request.cwd != cwd) return false + val next = if (failed != null && failed.request.cwd == cwd) failed.request else Request(newId(), cwd) + mutableState.value = State.Creating(next) + next + } + scope.launch { + try { + attempt(request) + } catch (cancelled: CancellationException) { + synchronized(lock) { + mutableState.value = State.Failed(request, "Creation was interrupted. Retry to check the same terminal.", false) + } + throw cancelled + } catch (error: Exception) { + synchronized(lock) { + mutableState.value = State.Failed(request, error.message ?: "Could not create the terminal.", definitiveFailure(error)) + } + return@launch + } + synchronized(lock) { mutableState.value = State.Ready(request) } + // The UI switches to Main and checks this ticket again before consuming/navigating. + if (isCurrent(ticket)) onCreated(request) + } + return true + } + + /** Consume only when the original visible screen is still allowed to navigate. */ + fun takeReady(ticket: Long): Request? = synchronized(lock) { + if (presentation != ticket) return null + val ready = mutableState.value as? State.Ready ?: return null + mutableState.value = State.Idle + ready.request + } +} + +object SshTerminalFolders { + data class Choice(val label: String, val cwd: String?) + + /** A driven-remotely project runs here; an sshTarget project runs on another computer. */ + fun choices(snapshot: ProjectsSnapshot): List = listOf(Choice("Home folder", null)) + + snapshot.projects.filter { it.sshTarget == null && it.cwd?.let(PhoneTerminals::validCwd) == true } + .distinctBy { it.cwd }.map { Choice(it.name, it.cwd) } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalCopy.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalCopy.kt new file mode 100644 index 0000000000..02f26c98f6 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalCopy.kt @@ -0,0 +1,127 @@ +package dev.nodeterm.protocol.model + +import dev.nodeterm.protocol.model.J.a + +/** + * The terminal screen's Copy sheet (audit A32). tmux runs `mouse on`, so xterm's own selection never + * runs on the phone, and tmux's copy-mode (which would copy through OSC 52) is out of reach of a touch + * screen. The sheet is the copy path that depends on neither: the page (terminal.js `nt.copySheet`) + * hands over the lines its buffer holds (the visible screen; under tmux the alternate screen keeps no + * scrollback, otherwise up to 500 rows of it) and the http(s) links in them; the user selects lines and + * copies or shares them, or opens or copies a link. + * + * The JSON comes from the page and is read like any other input: a wrong-typed field degrades, and + * sizes are bounded here too. + */ +object TerminalCopy { + /** Lines kept from a snapshot, the last ones (the page sends at most 500 rows' worth). */ + const val MAX_LINES = 1_000 + + /** Links kept from a snapshot, the last ones (the page sends at most 50). */ + const val MAX_LINKS = 50 + + /** A snapshot larger than this is refused unread: 1,000 lines of 1,000 columns, escaped. */ + const val MAX_JSON = 4_000_000 + + /** + * What the page's buffer held: its [lines] (soft wraps joined, each right-trimmed), the http(s) + * [links] in them, and [firstVisible], the line at the top of the screen the user was looking at + * (where the sheet opens). + */ + data class Snapshot(val lines: List, val links: List, val firstVisible: Int) + + /** Keep matching cheap even when the captured output repeats a short query thousands of times. */ + const val MAX_SEARCH_MATCHES = 2_000 + + /** UTF-16 offsets, matching Kotlin strings and Compose's text spans; [end] is exclusive. */ + data class Match(val line: Int, val start: Int, val end: Int) + + data class Search(val matches: List, val truncated: Boolean = false) { + /** Previous/next wraps; a missing or stale cursor starts at the appropriate edge. */ + fun move(current: Int?, forward: Boolean): Int? { + if (matches.isEmpty()) return null + if (current == null || current !in matches.indices) return if (forward) 0 else matches.lastIndex + return if (forward) (current + 1) % matches.size else (current + matches.size - 1) % matches.size + } + } + + /** + * Case-insensitive literal search of this captured snapshot, without changing row identities or + * selection. Whitespace within a nonblank query is significant. Matches on a row do not overlap. + * This is not a search of remote tmux history: the sheet holds only what the page captured. + */ + fun search(lines: List, query: String): Search { + if (query.isBlank()) return Search(emptyList()) + val matches = ArrayList() + for ((row, line) in lines.withIndex()) { + var from = 0 + while (from <= line.length - query.length) { + val start = line.indexOf(query, startIndex = from, ignoreCase = true) + if (start < 0) break + if (matches.size == MAX_SEARCH_MATCHES) return Search(matches, truncated = true) + matches.add(Match(row, start, start + query.length)) + from = start + query.length + } + } + return Search(matches) + } + + /** Null when [json] is not a snapshot at all. */ + fun parse(json: String): Snapshot? { + if (json.length > MAX_JSON) return null + val o = J.obj(J.parse(json)) ?: return null + val raw = J.arr(o["lines"]) ?: return null + val all = raw.map { J.str(it) ?: "" } + val lines = all.takeLast(MAX_LINES) + val dropped = all.size - lines.size + val first = (J.long(o["firstVisible"]) ?: 0L) - dropped + val firstVisible = first.coerceIn(0L, maxOf(0, lines.size - 1).toLong()).toInt() + val links = o.a("links").mapNotNull { J.str(it)?.let(ExternalLink::parse) } + .distinctBy { it.url } + .takeLast(MAX_LINKS) + return Snapshot(lines, links, firstVisible) + } + + /** + * Which lines are selected. A tap toggles one line and makes it the [anchor]; a long-press selects + * every line from the anchor to the pressed one (a range, without tapping each line). + */ + data class Selection(val selected: Set = emptySet(), val anchor: Int? = null) { + fun toggle(line: Int): Selection = copy(selected = if (line in selected) selected - line else selected + line, anchor = line) + + fun extendTo(line: Int): Selection { + val from = anchor ?: return copy(selected = selected + line, anchor = line) + return copy(selected = selected + (minOf(from, line)..maxOf(from, line)), anchor = line) + } + + fun all(count: Int): Selection = Selection((0 until count).toSet()) + + fun clear(): Selection = Selection() + } + + sealed interface Text { + /** Copy or share [text], [lines] lines of it. Never empty and never over [Osc52.MAX_TEXT_CHARS]. */ + data class Copy(val text: String, val lines: Int) : Text + + /** Nothing selected, or only empty lines. */ + data object Empty : Text + + /** + * Over [Osc52.MAX_TEXT_CHARS]: the clipboard write, like a share, is a binder call whose shared + * buffer is about 1 MB (audit A53), so the sheet keeps to the OSC 52 copy's cap. + */ + data object TooLarge : Text + } + + /** The selected [lines], in screen order whatever order they were selected in, one per line. */ + fun text(lines: List, selection: Selection): Text { + val picked = selection.selected.filter { it in lines.indices }.sorted() + if (picked.isEmpty()) return Text.Empty + var length = picked.size - 1L + for (i in picked) length += lines[i].length + if (length > Osc52.MAX_TEXT_CHARS) return Text.TooLarge + val text = picked.joinToString("\n") { lines[it] } + if (text.isBlank()) return Text.Empty + return Text.Copy(text, picked.size) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalDrafts.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalDrafts.kt new file mode 100644 index 0000000000..c1ec7a4f24 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalDrafts.kt @@ -0,0 +1,112 @@ +package dev.nodeterm.protocol.model + +import dev.nodeterm.protocol.host.ComposedCompletion +import dev.nodeterm.protocol.host.ComposedInputResult +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Full editor values belong to a live navigation entry, not to its current composition. Kept only + * in this process: no command text goes into the Activity Bundle or a file. Popping/replacing an + * entry, forgetting its host, or starting a different stack retires it and releases its value. + */ +class TerminalDrafts(private val empty: V) { + data class State(val value: V, val revision: Long = 0, val ctrl: CtrlModifier = CtrlModifier(), + val pendingSend: SendAttempt? = null, val sendNotice: SendNotice? = null) + + /** Identity, not a reusable counter: an old entry or callback cannot settle another attempt. */ + class SendAttempt internal constructor(val revision: Long, val ctrl: CtrlModifier) + class SendNotice internal constructor(val status: ComposedInputResult.Status, val message: String) + class Submission internal constructor(val value: V, val attempt: SendAttempt) { + val revision: Long get() = attempt.revision + val ctrl: CtrlModifier get() = attempt.ctrl + } + + class Entry internal constructor(val owner: String, private val empty: V) { + private val lock = Any() + private var retired = false + private val current = MutableStateFlow(State(empty)) + val state: StateFlow> = current.asStateFlow() + + /** Even an edit away and back to identical text is a new draft. */ + fun edit(value: V): Boolean = synchronized(lock) { + if (retired) return false + current.value = current.value.copy(value = value, revision = current.value.revision + 1) + true + } + + fun setCtrl(value: CtrlModifier): Boolean = synchronized(lock) { + if (retired) return false + current.value = current.value.copy(ctrl = value) + true + } + + /** Reserve before JS preparation, from the accepted editor rather than a collected UI value. */ + fun beginSend(): Submission? = synchronized(lock) { + if (retired || current.value.pendingSend != null) return null + val before = current.value + val attempt = SendAttempt(before.revision, before.ctrl) + current.value = before.copy(pendingSend = attempt) + Submission(before.value, attempt) + } + + /** Outcome survives coverage; delivery-side clearing still needs the original current viewer. */ + fun completeSend(attempt: SendAttempt, result: ComposedInputResult, currentViewer: Boolean): Boolean = synchronized(lock) { + if (retired || current.value.pendingSend !== attempt) return false + val outcome = if (!currentViewer && result.status == ComposedInputResult.Status.DELIVERED) + ComposedInputResult.uncertain() else result + val notice = when (outcome.status) { + ComposedInputResult.Status.DELIVERED -> current.value.sendNotice + ComposedInputResult.Status.REFUSED -> current.value.sendNotice?.takeIf { + it.status == ComposedInputResult.Status.UNCERTAIN + } ?: SendNotice(outcome.status, outcome.message ?: "Send was not sent. The draft was kept.") + ComposedInputResult.Status.UNCERTAIN -> SendNotice(outcome.status, + outcome.message ?: "Send could not be confirmed. Check the terminal before sending the retained draft again.") + } + current.value = current.value.copy(pendingSend = null, sendNotice = notice) + true + } + + /** A delayed dismissal cannot hide guidance from a newer attempt. */ + fun dismissSendNotice(notice: SendNotice): Boolean = synchronized(lock) { + if (retired || current.value.sendNotice !== notice) return false + current.value = current.value.copy(sendNotice = null) + true + } + + /** The callback reads this entry now, including edits accepted before UI recomposition. */ + fun clearUnchangedDraft(sentRevision: Long): Boolean = synchronized(lock) { + if (retired) return false + ComposedCompletion.clearUnchangedDraft(sentRevision, current.value.revision) { edit(empty) } + } + + internal fun retire() = synchronized(lock) { + retired = true + current.value = State(empty, current.value.revision + 1) + } + } + + private val entries = mutableMapOf>() + + @Synchronized + fun entry(key: String, owner: String): Entry { + val previous = entries[key] + if (previous != null && previous.owner == owner) return previous + previous?.retire() + return Entry(owner, empty).also { entries[key] = it } + } + + /** Reconcile against the whole live stack, including a newly restored or fresh Activity. */ + @Synchronized + fun retain(keys: Set) { + val removed = entries.keys.filter { it !in keys } + removed.forEach { entries.remove(it)?.retire() } + } + + @Synchronized + fun retireOwner(owner: String) { + val removed = entries.filterValues { it.owner == owner }.keys.toList() + removed.forEach { entries.remove(it)?.retire() } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalHistory.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalHistory.kt new file mode 100644 index 0000000000..d22a38c304 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/TerminalHistory.kt @@ -0,0 +1,61 @@ +package dev.nodeterm.protocol.model + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.longOrNull + +/** A host-side literal, case-sensitive search of all retained terminal output. */ +object TerminalHistory { + const val MAX_QUERY = 256 + const val MAX_ROWS = 200 + const val MAX_BYTES = 256 * 1024 + const val CAPTURE_BYTES = 50 * 1024 * 1024 + data class Row(val line: Int, val text: String) + data class Result(val searchedLines: Int, val truncated: Boolean, val rows: List) + + fun validQuery(query: String): Boolean = query.isNotEmpty() && query.length <= MAX_QUERY && + query.none { it.code < 32 || it.code == 127 } + + private fun checked(lines: Long?, truncated: Boolean?, rows: List, query: String): Result? { + if (!validQuery(query) || lines == null || lines !in 0..Int.MAX_VALUE.toLong() || truncated == null || rows.size > MAX_ROWS) return null + var previous = -1 + var bytes = 0L + for (row in rows) { + if (row.line <= previous || row.line >= lines || !row.text.contains(query) || '\n' in row.text || '\r' in row.text) return null + previous = row.line + bytes += row.text.toByteArray(Charsets.UTF_8).size + row.line.toString().length + 2 + if (bytes > MAX_BYTES) return null + } + return Result(lines.toInt(), truncated, rows) + } + + fun parseRelay(body: JsonElement?, query: String): Result? { + val o = J.obj(body) ?: return null + val raw = J.arr(o["rows"]) ?: return null + if (raw.size > MAX_ROWS) return null + val rows = raw.map { value -> + val row = J.obj(value) ?: return null + val line = number(row["line"]) ?: return null + if (line !in 0..Int.MAX_VALUE.toLong()) return null + Row(line.toInt(), J.str(row["text"]) ?: return null) + } + return checked(number(o["searchedLines"]), J.bool(o["truncated"]), rows, query) + } + private fun number(value: JsonElement?): Long? = (value as? JsonPrimitive)?.takeUnless { it.isString }?.longOrNull + + /** SSH emits a bounded, framed line protocol; text is never interpreted as a command. */ + fun parseSsh(text: String, query: String): Result? { + if (text.toByteArray(Charsets.UTF_8).size > MAX_BYTES + 128) return null + val lines = text.split('\n').toMutableList() + if (lines.lastOrNull() == "") lines.removeAt(lines.lastIndex) + val header = lines.firstOrNull()?.split('\t') ?: return null + if (header.size != 3 || header[0] != "NT-HISTORY-1" || header[2] !in listOf("0", "1")) return null + if (lines.size - 1 > MAX_ROWS) return null + val rows = lines.drop(1).map { value -> + val split = value.indexOf('\t') + if (split < 0) return null + Row(value.substring(0, split).toIntOrNull() ?: return null, value.substring(split + 1)) + } + return checked(header[1].toLongOrNull(), header[2] == "1", rows, query) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/UsagePace.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/UsagePace.kt new file mode 100644 index 0000000000..6388a74707 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/UsagePace.kt @@ -0,0 +1,163 @@ +package dev.nodeterm.protocol.model + +/** + * The Usages tab's pace line (docs/mobile-usage-inbox.md → "iOS behavior": *compare `usedPercent` + * vs elapsed fraction of the window (`windowMinutes` else 300/10080 by kind) → "5h usage pace + * slower/faster"*). It answers one question the bare percentage cannot: at the rate this window is + * being used, will it run out before it resets? + * + * Every refusal below returns null, and null means the card shows NO pace line — a guess must + * degrade to nothing, never to a confident wrong verdict: + * - no `resetsAt`: without the window's end there is no elapsed share to compare against; + * - no window length: the limit neither reports `windowMinutes` nor has a kind whose length is + * known (a future provider's kind is not assumed to be weekly); + * - `resetsAt` already passed: the percentage belongs to a window that is over; + * - more time left than the whole window: the length we used is wrong for this limit (a plan whose + * session window is not five hours), so any verdict built on it would be too. + * + * The elapsed share is taken at the moment `usedPercent` was MEASURED (the account's `updatedAt`), + * not at the moment the phone draws it. The desktop fetches Claude usage every 15 minutes + * (`POLL_MS`, usage-service.ts), and a mirror read over SSH can be far older than that when the + * desktop app is not running. Usage only grows inside a window, so comparing an old percentage with + * the elapsed share NOW can only push the verdict toward "slower": 15 minutes is five points of a + * 5h window, the whole [ON_PACE_BAND], so someone burning through the window would be told they are + * on pace. Measured at the snapshot's own time, the line describes the same moment as the bar above + * it. `now` is still what decides the "already reset" refusal. + * + * Both clocks are passed in, so the verdict is a pure function of its inputs. + */ +object UsagePace { + /** Claude's session window, the only length its `/api/oauth/usage` implies by `kind`. Claude + * reports no `windowMinutes` at all (`claude-usage-map.ts`), so without this default the pace + * line would never appear for the one provider the mirror publishes today. */ + const val SESSION_WINDOW_MINUTES = 300L + + /** The weekly windows (`weekly_all`, `weekly_scoped`). */ + const val WEEKLY_WINDOW_MINUTES = 10_080L + + /** Within this many percentage points of the elapsed share, usage is "on pace": a point or two + * either way is noise, not a trend worth a word. Inclusive at the edge. */ + const val ON_PACE_BAND = 5.0 + + enum class Pace { SLOWER, ON_PACE, FASTER } + + /** + * One limit's pace. [usedPercent] and [elapsedPercent] are both 0–100 shares of the SAME + * window ([windowMinutes] long), taken at the SAME moment (when the percentage was measured), + * which is what makes them comparable. + */ + data class Reading( + val pace: Pace, + val windowMinutes: Long, + val usedPercent: Double, + val elapsedPercent: Double + ) { + /** "5h usage pace faster", "7d usage pace slower", "5h usage on pace". */ + val line: String + get() { + val window = windowLabel(windowMinutes) + return when (pace) { + Pace.SLOWER -> "$window usage pace slower" + Pace.FASTER -> "$window usage pace faster" + Pace.ON_PACE -> "$window usage on pace" + } + } + } + + /** + * The window length this limit's pace is measured against: the provider's own `windowMinutes` + * when it reports a usable one (Codex does, and it varies by plan, so it always wins), else the + * default for its kind, else null. `kind` is checked before `group` because it is the more + * specific of the two; a non-positive `windowMinutes` from a hand-edited mirror is ignored. + */ + fun windowMinutes(limit: UsageLimit): Long? { + limit.windowMinutes?.takeIf { it > 0 }?.let { return it } + val kind = limit.kind + return when { + kind == "session" -> SESSION_WINDOW_MINUTES + kind == "weekly" || kind.startsWith("weekly_") -> WEEKLY_WINDOW_MINUTES + limit.group == "session" -> SESSION_WINDOW_MINUTES + limit.group == "weekly" -> WEEKLY_WINDOW_MINUTES + else -> null + } + } + + /** + * When a limit's `usedPercent` was measured, given the snapshot's [updatedAt]: that time, unless + * it is unknown (0, the parser's default for a missing field, or a hostile negative) or later + * than [now] (a desktop clock ahead of the phone's), in which case [now]: with no usable + * measurement time there is nothing to correct the drift with. + */ + fun measurementTime(updatedAt: Long, now: Long): Long = if (updatedAt in 1..now) updatedAt else now + + /** + * The pace of [limit] as measured at [measuredAt] (unix ms; pass the account's `updatedAt`, + * see [measurementTime] for how an unusable one is treated), read at [now] (unix ms), or + * null when there is nothing honest to say. + */ + fun of(limit: UsageLimit, measuredAt: Long, now: Long): Reading? { + val resetsAt = limit.resetsAt ?: return null + val minutes = windowMinutes(limit) ?: return null + val at = measurementTime(measuredAt, now) + // Doubles, not Longs: both values come from a hand-editable file, and `resetsAt - now` or + // `minutes * 60_000` on a hostile value would wrap around instead of being refused. + val windowMs = minutes.toDouble() * 60_000.0 + // The window has reset by the time the phone draws it: the percentage is the old window's. + if (resetsAt.toDouble() - now.toDouble() <= 0.0) return null + // `at <= now < resetsAt`, so this is positive. More than the whole window means the length + // is wrong for this limit, or the measurement time does not belong to this window. + val remainingMs = resetsAt.toDouble() - at.toDouble() + if (remainingMs > windowMs) return null + val elapsedPercent = (1.0 - remainingMs / windowMs) * 100.0 + val used = limit.usedPercent + if (used.isNaN()) return null + val usedPercent = used.coerceIn(0.0, 100.0) + val lead = usedPercent - elapsedPercent + val pace = when { + lead > ON_PACE_BAND -> Pace.FASTER + lead < -ON_PACE_BAND -> Pace.SLOWER + else -> Pace.ON_PACE + } + return Reading(pace, minutes, usedPercent, elapsedPercent) + } + + /** + * A window length as a short label, derived from the minutes the pace was measured against — + * never from `kind` alone ("labelling a window '5h' from its kind alone can be a lie", + * `UsageLimit.windowMinutes` in src/shared/types.ts): 300 → "5h", 10080 → "7d", 90 → "90m". + */ + fun windowLabel(minutes: Long): String = when { + minutes > 0 && minutes % 1_440L == 0L -> "${minutes / 1_440L}d" + minutes > 0 && minutes % 60L == 0L -> "${minutes / 60L}h" + else -> "${minutes}m" + } +} + +/** + * A node's context-window fill (`inbox.nodes[nodeId].contextPercent`, docs/mobile-usage-inbox.md), + * as every Android surface shows it: the sessions row, the live working card and the approval, + * question and done cards. One definition, so the same node cannot read 41% on one card and 42% on + * the next. + */ +object ContextFill { + enum class Level { OK, HIGH, CRITICAL } + + /** The fill clamped to 0–100 and rounded like the desktop's `percentText` (`Math.round`), or + * null when unknown. The desktop clamps before writing; the mirror is hand-editable anyway. */ + fun percent(contextPercent: Double?): Int? { + val p = contextPercent ?: return null + if (p.isNaN()) return null + return Math.round(p.coerceIn(0.0, 100.0)).toInt() + } + + /** "42% context", or null when the fill is unknown. */ + fun label(contextPercent: Double?): String? = percent(contextPercent)?.let { "$it% context" } + + /** The desktop context meter's colour bands (`contextFillColor`, src/renderer/lib/usageFormat.ts): + * above 85 red, from 60 yellow, else green — judged on the unrounded value, as there. */ + fun level(contextPercent: Double): Level = when { + contextPercent > 85.0 -> Level.CRITICAL + contextPercent >= 60.0 -> Level.HIGH + else -> Level.OK + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt new file mode 100644 index 0000000000..6352720756 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt @@ -0,0 +1,321 @@ +package dev.nodeterm.protocol.pairing + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.crypto.E2ee +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.ssh.HostKeyAnchors +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.InetSocketAddress +import java.net.Socket +import java.util.concurrent.atomic.AtomicBoolean + +class PairingException(message: String) : Exception(message) { + companion object { + /** + * What the pairing screen shows for a failed [PairingClient.pair]: a PairingException's own + * sentence as it is, and anything else framed as one — a bare exception message can be a + * lone token (audit A54: a negative Content-Length used to reach the screen as "-1"). + */ + fun userMessage(e: Throwable): String { + val own = e.message?.trim().orEmpty() + if (e is PairingException && own.isNotEmpty()) return own + val detail = if (own.isNotEmpty()) "${e.javaClass.simpleName}: ${own.take(200)}" else e.javaClass.simpleName + return "Pairing failed unexpectedly ($detail). Scan the code again." + } + + /** The longest refusal text from the computer shown on the pairing screen. The desktop's + * own refusals are one sentence (under 200 characters); the endpoint is whatever the + * pairing payload names, so what it sends is untrusted and must not fill the screen. */ + const val MAX_REFUSAL_CHARS = 300 + + /** A non-2xx body as the pairing screen may show it: control characters dropped (a line + * break or an escape sequence has no business in a one-line error), whitespace collapsed, + * and cut at [MAX_REFUSAL_CHARS]. Empty when nothing printable is left. */ + fun refusalText(body: String): String { + val flat = body.map { if (it.isISOControl() || it == '\u2028' || it == '\u2029') ' ' else it } + .joinToString("").trim().replace(Regex("\\s+"), " ") + return if (flat.length <= MAX_REFUSAL_CHARS) flat else flat.take(MAX_REFUSAL_CHARS).trimEnd() + "…" + } + } +} + +/** + * The phone side of `src/main/pairing-service.ts`'s `/pair` listener. + * + * Request body `{token, publicKey, deviceName, deviceId, priorDeviceToken?, boxPublicKey?}`. + * `boxPublicKey` (the phone's persistent relay identity) is sent ONLY inside the sealed body; a + * desktop that knows it pins it on its standing host (`relayPinned: true`, audit A07), or records it + * to pin on the phone's first relay handshake when the pairing had no relay leg (A07-late), and + * answers `relayApproved: true` either way, so the first relay connect needs no approval at the desk + * even after a late adoption. An older desktop ignores it. When the QR carried a + * `hostKey`, the whole body is sealed to it — `{epk: , box: base64(nonce ‖ + * secretbox)}` under `box.before(hostKey, ephemeralSecret)` — and the answer comes back sealed the + * same way, so the relay device token never crosses the LAN in the clear. A sealed answer may also + * name the computer's SSH host keys (`sshHostKeyFingerprints`, audit A49-anchor), which the first SSH + * connect is then checked against; a plaintext answer's are ignored. + * + * Spoken over a RAW TCP socket rather than an HTTP client, like the iOS app: a bare-IP `http://` + * URL is cleartext traffic Android's network security policy blocks by default for HTTP stacks, + * and the desktop frames every response with an explicit Content-Length precisely so a minimal + * reader works (pairing-service.ts `send`). A non-2xx body is plain text meant for the user. + * + * The endpoint is whatever `host:pairPort` the payload names, and a `nodeterm://pair` link from any + * web page can supply one, so the answer is read as untrusted (audit A54): its size is capped at + * [MAX_RESPONSE_BYTES] whether or not it declares a Content-Length, and the whole exchange — + * connect included — is bounded by [deadlineMs]. Every refusal is a [PairingException] whose + * message is a sentence, because PairScreen shows the message as it is. + */ +class PairingClient( + private val connectTimeoutMs: Int = 8_000, + /** Bounds SILENCE only: a server that trickles a byte at a time never trips it. */ + private val readTimeoutMs: Int = 20_000, + /** Bounds the whole exchange. Enforced by closing the socket, the one thing that unblocks a read. */ + private val deadlineMs: Long = DEFAULT_DEADLINE_MS +) { + companion object { + /** + * Largest header block or body accepted. The desktop's biggest real answer — the sealed + * `{box}` around `{ok, deviceId, agentToken, relay, relayDeviceToken, relayPinned, relayApproved, + * sshHostKeyFingerprints}` with the most host keys it sends (16) — measured 1,803 bytes, headers + * included, with the interop fixture's short relay token (634 without the host keys; + * PairingInteropTest counts it through a proxy and fails past 4 KiB). 64 KiB is also the + * cap the desktop puts on the request it reads from us (pairing-service.ts `MAX_BODY_BYTES`). + */ + const val MAX_RESPONSE_BYTES = 64 * 1024 + + /** + * The desktop's slowest honest answer waits behind an in-flight revoke (8 s) and then a + * relay-device mint (8 s) before it replies; 45 s leaves room for a slow network on top. + */ + const val DEFAULT_DEADLINE_MS = 45_000L + + private const val SCAN_AGAIN = + "Make sure the code came from nodeterm on your computer, then scan it again." + } + + suspend fun pair( + payload: PairingPayload, + sshPublicKeyLine: String, + deviceName: String, + deviceId: String, + priorDeviceToken: String? = null, + boxPublicKeyB64: String? = null + ): PairingResult = withContext(Dispatchers.IO) { + val inner = buildJsonObject { + put("token", payload.token) + put("publicKey", sshPublicKeyLine) + put("deviceName", deviceName) + put("deviceId", deviceId) + priorDeviceToken?.let { put("priorDeviceToken", it) } + // Only ever sealed: a plaintext body could be rewritten on the LAN, and the desktop + // ignores the field there anyway. + if (payload.hostKey != null) boxPublicKeyB64?.let { put("boxPublicKey", it) } + }.toString() + + var shared: ByteArray? = null + val body = if (payload.hostKey != null) { + val eph = BoxKeyPair.generate() + val key = E2ee.deriveSharedKey(payload.hostKey, eph.secretKey) + shared = key + buildJsonObject { + put("epk", eph.publicKeyB64) + put("box", B64.encode(E2ee.encrypt(inner.toByteArray(Charsets.UTF_8), key))) + }.toString() + } else { + inner + } + + val (status, text) = exchange(payload.host, payload.pairPort, "/pair", body) + if (status !in 200..299) { + val said = PairingException.refusalText(text) + throw PairingException( + if (said.isNotEmpty()) "The computer rejected pairing: $said" + else "The computer rejected pairing (HTTP $status)." + ) + } + var obj = J.obj(J.parse(text)) ?: throw PairingException("The computer answered with something that is not JSON.") + if (shared != null) { + val sealed = obj.s("box")?.let(B64::decode) + ?: throw PairingException("The computer's answer was not encrypted as expected.") + val plain = E2ee.decrypt(sealed, shared) + ?: throw PairingException("Couldn't decrypt the computer's answer — was the QR code from this computer?") + obj = J.obj(J.parse(String(plain, Charsets.UTF_8))) + ?: throw PairingException("The computer's answer was not valid JSON.") + } + if (obj.b("ok") != true) throw PairingException("The computer did not confirm the pairing.") + val pinned = obj.b("relayPinned") == true + // Only from the sealed answer (A49-anchor): a plaintext one could have been rewritten on the + // LAN, and the desktop never puts the field there. + val sshHostKeys = if (shared != null) HostKeyAnchors.parse(obj["sshHostKeyFingerprints"]) else emptyList() + PairingResult( + deviceId = obj.s("deviceId") ?: throw PairingException("The computer did not assign a device id."), + agentToken = obj.s("agentToken") ?: "", + relay = obj.o("relay")?.let(PairingPayload::parseRelayBlock), + relayDeviceToken = obj.s("relayDeviceToken"), + relayPinned = pinned, + // A desktop from before A07-late answers only `relayPinned`, which implies it. + relayApproved = pinned || obj.b("relayApproved") == true, + sshHostKeyFingerprints = sshHostKeys + ) + } + + /** + * One POST under the overall deadline. A watchdog closes the socket when [deadlineMs] runs out + * — and when the caller is cancelled (PairScreen left), since cancellation alone does not + * unblock a thread parked in a socket read. + */ + private suspend fun exchange(host: String, port: Int, path: String, body: String): Pair = coroutineScope { + val sock = Socket() + val expired = AtomicBoolean(false) + // UNDISPATCHED: the `try` is entered before the socket is used, so even a cancellation that + // lands before the watchdog would otherwise have been scheduled still closes the socket. + val watchdog = launch(start = CoroutineStart.UNDISPATCHED) { + try { + delay(deadlineMs) + expired.set(true) + } finally { + runCatching { sock.close() } + } + } + try { + postRaw(sock, host, port, path, body) + } catch (e: IOException) { + ensureActive() // the caller walked away: report that, not a network failure + if (expired.get()) { + throw PairingException( + "The computer at $host:$port did not finish answering within ${seconds(deadlineMs)}, " + + "so pairing stopped. Check that the phone is on the same network and the pairing code " + + "is still on screen, then scan it again." + ) + } + throw PairingException( + "Couldn't reach the computer at $host:$port (${e.message ?: e.javaClass.simpleName}). " + + "Is the phone on the same network, and is the pairing code still on screen?" + ) + } finally { + watchdog.cancel() + } + } + + private fun seconds(ms: Long): String { + val n = (ms + 999) / 1000 + return if (n == 1L) "1 second" else "$n seconds" + } + + private fun postRaw(sock: Socket, host: String, port: Int, path: String, body: String): Pair { + val bytes = body.toByteArray(Charsets.UTF_8) + sock.use { + sock.connect(InetSocketAddress(host, port), connectTimeoutMs) + sock.soTimeout = readTimeoutMs + val hostHeader = if (host.contains(':')) "[$host]:$port" else "$host:$port" + val head = "POST $path HTTP/1.1\r\n" + + "Host: $hostHeader\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: ${bytes.size}\r\n" + + "Connection: close\r\n\r\n" + val out = sock.getOutputStream() + out.write(head.toByteArray(Charsets.US_ASCII)) + out.write(bytes) + out.flush() + return readResponse(sock.getInputStream()) + } + } + + /** + * Minimal HTTP/1.1 response reader: status line, headers, then a Content-Length (or EOF) body, + * each capped at [MAX_RESPONSE_BYTES]. A declared length is checked BEFORE anything is + * allocated for it: a negative one used to surface as the bare "-1" of a + * NegativeArraySizeException, and a huge one (or none, with an endless body) as an + * OutOfMemoryError that took the app down. + */ + internal fun readResponse(input: InputStream): Pair { + val headBytes = ByteArrayOutputStream() + var last4 = 0 + while (true) { + val c = input.read() + if (c < 0) break + headBytes.write(c) + last4 = (last4 shl 8) or c + if (last4 == 0x0d0a0d0a) break + if (headBytes.size() > MAX_RESPONSE_BYTES) { + throw PairingException("The computer's answer had more than 64 KiB of headers, so pairing stopped. $SCAN_AGAIN") + } + } + val head = headBytes.toString(Charsets.ISO_8859_1.name()) + val lines = head.split("\r\n") + val status = lines.firstOrNull()?.split(' ')?.getOrNull(1)?.toIntOrNull() + ?: throw IOException("malformed HTTP response") + val declared = lines.drop(1).firstNotNullOfOrNull { line -> + val idx = line.indexOf(':') + if (idx > 0 && line.substring(0, idx).trim().equals("content-length", ignoreCase = true)) { + line.substring(idx + 1).trim() + } else null + } + val body = if (declared != null) { + val length = declaredLength(declared) + val buf = ByteArray(length) + var off = 0 + while (off < length) { + val n = input.read(buf, off, length - off) + if (n < 0) break + off += n + } + buf.copyOf(off) + } else { + readToEof(input) + } + return status to String(body, Charsets.UTF_8) + } + + /** A Content-Length value, refused with a sentence unless it is a count from 0 to the cap. */ + private fun declaredLength(raw: String): Int { + val n = raw.toLongOrNull() + return when { + n != null && n < 0 -> throw PairingException( + "The computer's answer declared a negative length ($n bytes), so pairing stopped. $SCAN_AGAIN" + ) + n != null && n > MAX_RESPONSE_BYTES -> tooLarge("it declared $n bytes") + n != null -> n.toInt() + // Too many digits for a Long is still a length, just an absurd one. + raw.isNotEmpty() && raw.all { it in '0'..'9' } -> tooLarge("it declared a ${raw.length}-digit length") + else -> throw PairingException( + "The computer's answer declared a length that is not a number, so pairing stopped. $SCAN_AGAIN" + ) + } + } + + private fun tooLarge(what: String): Nothing = throw PairingException( + "The computer's answer is larger than any pairing answer ($what; the limit is 64 KiB), so pairing stopped. $SCAN_AGAIN" + ) + + /** No Content-Length: read to EOF, but never past the cap. */ + private fun readToEof(input: InputStream): ByteArray { + val out = ByteArrayOutputStream() + val chunk = ByteArray(8 * 1024) + while (true) { + val n = input.read(chunk) + if (n < 0) return out.toByteArray() + out.write(chunk, 0, n) + if (out.size() > MAX_RESPONSE_BYTES) { + throw PairingException( + "The computer's answer ran past 64 KiB without ending, so pairing stopped. $SCAN_AGAIN" + ) + } + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt new file mode 100644 index 0000000000..3116fae860 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingPayload.kt @@ -0,0 +1,145 @@ +package dev.nodeterm.protocol.pairing + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.s +import kotlinx.serialization.json.JsonObject +import java.net.URI + +/** `RelayPairingBlock` (pairing-core.ts): how to reach this host over the relay. */ +data class RelayBlock(val hostId: String, val hostPublicKeyB64: String, val relayEndpoint: String) + +/** + * The QR the desktop shows under Settings → Phone (`buildPairingPayload`, pairing-core.ts): + * `{"v":1,"host":…,"port":22,"user":…,"token":…,"pairPort":N,"nodeterm":true,"name":…, + * "hostKey"?:…, "relay"?:{…}, "ssh"?:false}`. + */ +data class PairingPayload( + val host: String, + val port: Int, + val user: String, + val token: String, + val pairPort: Int, + val name: String, + /** The host's NaCl box key: seal `/pair` to it. Also the relay host key. */ + val hostKey: String?, + val relay: RelayBlock?, + /** False on a Windows host: no SSH key is installed; the phone must use the relay. */ + val sshAvailable: Boolean +) { + companion object { + /** `@shared/pair-qr` PAIR_URL_PREFIX: the URL envelope a system camera can open. */ + const val PAIR_URL_PREFIX = "nodeterm://pair?code=" + + /** + * The three shapes the desktop can hand over (`@shared/pair-qr`, and what the iOS app's + * `PairingService.decode` accepts): the raw payload JSON, the `nodeterm://pair?code=` + * URL wrapping base64url(JSON), or the bare base64url code. + */ + fun unwrap(text: String): String? { + val t = text.trim() + if (t.isEmpty()) return null + if (t.startsWith("{")) return t + val code = if (t.contains("://")) { + val uri = try { + URI(t) + } catch (_: Exception) { + return null + } + if (uri.scheme != "nodeterm" || uri.host != "pair") return null + if (!uri.path.isNullOrEmpty() && uri.path != "/") return null + uri.rawQuery?.split('&')?.firstOrNull { it.startsWith("code=") }?.removePrefix("code=") ?: return null + } else t + return B64.decodeUrl(code)?.toString(Charsets.UTF_8) + } + + /** Null for anything that is not a nodeterm PHONE pairing payload — never throws. (A desktop-peer + * relay offer shares the URL envelope but not the payload, and is refused here.) */ + fun parse(text: String): PairingPayload? { + val o = J.obj(J.parse(unwrap(text) ?: return null)) ?: return null + if (o.l("v") != 1L || o.b("nodeterm") != true) return null + val host = o.s("host")?.takeIf { it.isNotBlank() } ?: return null + val user = o.s("user")?.takeIf { it.isNotBlank() } ?: return null + val token = o.s("token")?.takeIf { it.isNotBlank() } ?: return null + val pairPort = o.l("pairPort")?.toInt()?.takeIf { it in 1..65535 } ?: return null + val port = o.l("port")?.toInt()?.takeIf { it in 1..65535 } ?: 22 + val hostKey = o.s("hostKey")?.takeIf { B64.decode(it)?.size == 32 } + return PairingPayload( + host = host, + port = port, + user = user, + token = token, + pairPort = pairPort, + name = o.s("name")?.takeIf { it.isNotBlank() } ?: host, + hostKey = hostKey, + relay = o.o("relay")?.let(::parseRelayBlock), + sshAvailable = o.b("ssh") != false + ) + } + + fun parseRelayBlock(r: JsonObject): RelayBlock? { + val hostId = r.s("hostId")?.takeIf { it.isNotBlank() } ?: return null + val pub = r.s("hostPublicKeyB64")?.takeIf { B64.decode(it)?.size == 32 } ?: return null + val endpoint = r.s("relayEndpoint")?.takeIf(::isAllowedRelayEndpoint) ?: return null + return RelayBlock(hostId, pub, endpoint) + } + + /** + * R5 (pairing.ts): the phone dials the relay endpoint verbatim, so a payload must not point + * it at plaintext — `wss:` only, `ws:` solely for loopback (a local relay in dev/tests). + */ + fun isAllowedRelayEndpoint(endpoint: String): Boolean { + val uri = try { + URI(endpoint) + } catch (_: Exception) { + return false + } + return when (uri.scheme) { + "wss" -> !uri.host.isNullOrEmpty() + "ws" -> uri.host in setOf("127.0.0.1", "localhost", "::1", "[::1]") + else -> false + } + } + } +} + +/** What `/pair` answered: the host-minted identity plus, when remote access is on, a relay leg. */ +data class PairingResult( + val deviceId: String, + val agentToken: String, + val relay: RelayBlock?, + val relayDeviceToken: String?, + /** The computer pinned this phone's relay key at pairing (audit A07). Implies [relayApproved]. */ + val relayPinned: Boolean = false, + /** + * The computer serves this phone's relay key without its approval dialog (audit A07-late): pinned + * at the scan, or recorded with this pairing for its standing host to pin on the phone's first + * relay connect. The second is what a phone paired while remote access was off needs: it adopts + * the relay later over SSH (late adoption), and its first relay connect then raises no dialog at + * a desk it has usually left. False from a desktop that predates it, which shows the dialog on + * the first relay connect. What gates the background worker's relay leg + * ([dev.nodeterm.protocol.host.RelayApprovalGate]). + */ + val relayApproved: Boolean = relayPinned, + /** + * The fingerprints of the computer's SSH host keys (audit A49-anchor), in the form sshj and OpenSSH + * print (`SHA256:…`). Taken ONLY from a sealed answer, which is bound to the host key the QR on the + * computer's screen carries: a plaintext one could have been rewritten on the LAN. Empty from a + * desktop that predates the field, from a Windows desktop (no SSH leg), when the desktop could not + * read its keys, and for a plaintext answer; the first SSH connect then pins the key of the first + * server that accepts the phone's key, as before. See [dev.nodeterm.protocol.ssh.HostKeyAnchors]. + */ + val sshHostKeyFingerprints: List = emptyList() +) { + /** What the Pair screen says once paired. */ + fun pairedNotice(): String = when { + relayDeviceToken == null && relayApproved -> + "Paired. This phone reaches the computer on your network, and is approved for remote access once the computer offers it." + relayDeviceToken == null -> "Paired. This phone reaches the computer on your network." + relayApproved -> "Paired, and approved for remote access." + else -> "Paired. The first time you connect from outside your network, approve this phone on the computer." + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/RelayPairingProof.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/RelayPairingProof.kt new file mode 100644 index 0000000000..794745f972 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/RelayPairingProof.kt @@ -0,0 +1,137 @@ +package dev.nodeterm.protocol.pairing + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.model.PairedHost +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import net.i2p.crypto.eddsa.EdDSAEngine +import java.security.MessageDigest + +/** Optional legacy association, only on a relay that just served an approved listing. No identity + * is created and no unacknowledged proof is replayed. Browsing does not depend on migration. */ +object RelayPairingProof { + const val CHALLENGE_METHOD = "pairing.relayKeyChallengeV1" + const val PROOF_METHOD = "pairing.relayKeyProofV1" + const val TIMEOUT_MS = 3_000L + private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991L + private val UUID = Regex("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}") + + class Context( + val host: PairedHost, + val pinnedHostKeyB64: String, + val peerPublicKeyB64: String, + /** Reads an existing encrypted SSH seed. Must never get-or-create an identity. */ + val retainedSshSeed: () -> ByteArray?, + /** The originating connection/paired-record lifetime, checked outside network awaits. */ + val requireCurrent: () -> Unit, + ) + + enum class Outcome { ASSOCIATED, UNPROVABLE, GONE, CONFLICT, EXPIRED } + + data class Challenge( + val challengeId: String, + val pairingId: String, + val hostPublicKeyB64: String, + val peerPublicKeyB64: String, + val sshPublicKeyB64: String, + val nonceB64: String, + val expiresAtMs: Long, + ) { + /** Matches src/shared/relay-pairing-proof.ts including the final newline. */ + fun signingBytes(): ByteArray = listOf("nodeterm-relay-pairing-v1", "1", challengeId, + pairingId, hostPublicKeyB64, peerPublicKeyB64, sshPublicKeyB64, nonceB64, + expiresAtMs.toString(), "").joinToString("\n").toByteArray(Charsets.UTF_8) + } + + private fun text(obj: JsonObject, name: String): String? = + (obj[name] as? JsonPrimitive)?.takeIf { it.isString }?.content + + private fun canonicalKey(text: String): ByteArray? { + if (text.length != 44) return null + return B64.decode(text)?.takeIf { it.size == 32 && B64.encode(it) == text } + } + + /** Strict types, encoding and safe host expiry. The host enforces its own monotonic TTL; + * comparing its full 60-second window to the phone clock would reject slight clock skew. */ + fun parseChallenge(body: JsonElement?, context: Context, sshPublicKey: ByteArray, now: Long): Challenge? { + val obj = (body as? JsonObject)?.takeIf { text(it, "status") == "challenge" }?.get("challenge") as? JsonObject ?: return null + val version = obj["version"] as? JsonPrimitive ?: return null + if (version.isString || version.intOrNull != 1) return null + val challengeId = text(obj, "challengeId")?.takeIf(UUID::matches) ?: return null + val pairingId = text(obj, "pairingId")?.takeIf { UUID.matches(it) && it == context.host.id } ?: return null + val host = text(obj, "hostPublicKeyB64")?.takeIf { canonicalKey(it) != null && it == context.pinnedHostKeyB64 } ?: return null + val peer = text(obj, "peerPublicKeyB64")?.takeIf { canonicalKey(it) != null && it == context.peerPublicKeyB64 } ?: return null + val ssh = text(obj, "sshPublicKeyB64") ?: return null + val rawSsh = canonicalKey(ssh) ?: return null + if (!MessageDigest.isEqual(rawSsh, sshPublicKey)) return null + val nonce = text(obj, "nonceB64")?.takeIf { canonicalKey(it) != null } ?: return null + val expiry = obj["expiresAtMs"] as? JsonPrimitive ?: return null + val expires = expiry.takeUnless { it.isString }?.longOrNull ?: return null + if (now < 0 || expires <= now || expires > MAX_SAFE_INTEGER) return null + return Challenge(challengeId, pairingId, host, peer, ssh, nonce, expires) + } + + private fun status(body: JsonElement?): Outcome? = when ((body as? JsonObject)?.let { text(it, "status") }) { + "associated" -> Outcome.ASSOCIATED + "unprovable" -> Outcome.UNPROVABLE + "gone" -> Outcome.GONE + "conflict" -> Outcome.CONFLICT + "expired" -> Outcome.EXPIRED + else -> null + } + + suspend fun afterApprovedListing( + context: Context, + rpc: suspend (String, JsonObject) -> JsonElement?, + now: () -> Long = System::currentTimeMillis, + timeoutMs: Long = TIMEOUT_MS, + ): Outcome { + suspend fun current() { + currentCoroutineContext().ensureActive() + context.requireCurrent() + } + try { + return withTimeoutOrNull(timeoutMs) { + current() + val host = context.host + if (host.manual || !UUID.matches(host.id) || canonicalKey(context.pinnedHostKeyB64) == null || + host.hostKeyB64 != context.pinnedHostKeyB64 || host.relayHostKeyB64 != context.pinnedHostKeyB64 || + canonicalKey(context.peerPublicKeyB64) == null) return@withTimeoutOrNull Outcome.UNPROVABLE + val seed = context.retainedSshSeed()?.takeIf { it.size == 32 } ?: return@withTimeoutOrNull Outcome.UNPROVABLE + val identity = SshIdentity.fromSeed(seed) + current() + val body = rpc(CHALLENGE_METHOD, buildJsonObject { put("pairingId", host.id) }) + current() + status(body)?.let { return@withTimeoutOrNull it } + val challenge = parseChallenge(body, context, identity.publicKeyBytes, now()) + ?: return@withTimeoutOrNull Outcome.UNPROVABLE + val signer = EdDSAEngine(MessageDigest.getInstance("SHA-512")) + signer.initSign(identity.keyPair.private) + signer.update(challenge.signingBytes()) + val signature = B64.encode(signer.sign()) + current() + if (now() >= challenge.expiresAtMs) return@withTimeoutOrNull Outcome.EXPIRED + val answer = rpc(PROOF_METHOD, buildJsonObject { + put("challengeId", challenge.challengeId) + put("signatureB64", signature) + }) + current() + status(answer) ?: Outcome.UNPROVABLE + } ?: Outcome.UNPROVABLE + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Missing verbs, unavailable Keystore and lost acknowledgements are all optional. + return Outcome.UNPROVABLE + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/SshIdentity.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/SshIdentity.kt new file mode 100644 index 0000000000..99eb2fd5a2 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/SshIdentity.kt @@ -0,0 +1,56 @@ +package dev.nodeterm.protocol.pairing + +import dev.nodeterm.protocol.crypto.B64 +import net.i2p.crypto.eddsa.EdDSAPrivateKey +import net.i2p.crypto.eddsa.EdDSAPublicKey +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec +import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.security.KeyPair +import java.security.SecureRandom + +/** + * The phone's SSH identity: an Ed25519 key generated ON the device. Pairing sends only the public + * half (`ssh-ed25519 AAAA… comment`), which the desktop appends to `~/.ssh/authorized_keys` under + * its own `nodeterm-ios-` comment (pairing-core.ts `rewriteKeyComment` — the comment the + * phone sends is replaced, so the Android comment here is cosmetic). The private seed never leaves + * the phone; the app keeps it encrypted under an Android Keystore key. + */ +class SshIdentity private constructor(val seed: ByteArray) { + private val spec = EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519) + private val privateSpec = EdDSAPrivateKeySpec(seed, spec) + + /** The raw 32-byte public point A. */ + val publicKeyBytes: ByteArray = privateSpec.a.toByteArray() + + val keyPair: KeyPair + get() = KeyPair(EdDSAPublicKey(EdDSAPublicKeySpec(privateSpec.a, spec)), EdDSAPrivateKey(privateSpec)) + + /** OpenSSH wire blob: string("ssh-ed25519") ‖ string(A). */ + val publicKeyBlob: ByteArray + get() { + val out = ByteArrayOutputStream() + DataOutputStream(out).use { d -> + val name = "ssh-ed25519".toByteArray(Charsets.US_ASCII) + d.writeInt(name.size) + d.write(name) + d.writeInt(publicKeyBytes.size) + d.write(publicKeyBytes) + } + return out.toByteArray() + } + + fun authorizedKeysLine(comment: String = "nodeterm-android"): String = + "ssh-ed25519 ${B64.encode(publicKeyBlob)} $comment" + + companion object { + fun generate(random: SecureRandom = SecureRandom()): SshIdentity = SshIdentity(ByteArray(32).also(random::nextBytes)) + + fun fromSeed(seed: ByteArray): SshIdentity { + require(seed.size == 32) { "Ed25519 seed must be 32 bytes" } + return SshIdentity(seed.copyOf()) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt new file mode 100644 index 0000000000..a58954b6f5 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/ApiBaseSetting.kt @@ -0,0 +1,93 @@ +package dev.nodeterm.protocol.relay + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +/** + * The relay API address the user can edit under Settings → Advanced, and what leaving that screen + * does with it (audit A44). The address is stored only when the user leaves the screen, never per + * keystroke: a half-typed value such as `https://a` would otherwise be the one every relay call + * used until the typing was done. + * + * Leaving without an edit stores nothing, and the built-in default is never stored as an address. + * The app's store answers [RelayApi.DEFAULT_API_BASE] while it holds no address, so a phone that + * holds none follows the default of whichever build it runs. Storing the default (as the first A44 + * fix did on every leave) would pin the phone to this build's default for good, and the field could + * not show the difference: the pinned and the followed default read the same. + */ +object ApiBaseSetting { + /** What leaving Settings does with the typed address. */ + sealed interface OnLeave { + /** Store [value]. */ + data class Save(val value: String) : OnLeave + + /** + * The built-in default: forget any stored address, so the phone follows the default, + * including a later build's. It is also the answer for an unedited field on a phone that + * stores no address, where forgetting one changes nothing. + */ + data object UseDefault : OnLeave + + /** + * What is already stored: nothing to store. That is an unedited address, or one we would + * not accept that an older build saved: a user who did not type it gets no message on + * leaving, though the field flags it ([fieldError]). + */ + data object Keep : OnLeave + + /** Not an address we accept: nothing is stored, and the user must be told so. */ + data object Rejected : OnLeave + } + + /** + * [typed] as it is to be stored (trimmed, without a trailing slash), or null when it is not an + * address the phone will use. It must start with `https://` (the device token and the relay + * tokens ride it), and be a URL with a host and without a query or fragment. The old rule was + * only the prefix check. It let `https://` through, which is stored as `https:` and which OkHttp + * then reads, with the `/v1/…` path [RelayApi] appends, as a host named `v1`. It let + * `https://?x`, `https://:443` and `https://a b` through, and with those every relay call threw + * IllegalArgumentException from OkHttp's `Request.Builder.url`, which [RelayApi] does not turn + * into a [RelayApiException] (measured with okhttp 4.12.0). A query or fragment swallows the + * appended path. + */ + fun accept(typed: String): String? { + val base = normalize(typed) + if (!base.startsWith("https://", ignoreCase = true)) return null + val url = base.toHttpUrlOrNull() ?: return null + if (!url.isHttps || url.query != null || url.fragment != null) return null + return base + } + + /** + * What leaving Settings does with [typed], given the address [stored] now ([RelayApi.DEFAULT_API_BASE] + * when none is stored). The default is checked first: it heals a phone an earlier build pinned to it. + */ + fun onLeave(typed: String, stored: String): OnLeave { + val unchanged = normalize(typed) == normalize(stored) + val accepted = accept(typed) ?: return if (unchanged) OnLeave.Keep else OnLeave.Rejected + return when { + accepted == RelayApi.DEFAULT_API_BASE -> OnLeave.UseDefault + unchanged -> OnLeave.Keep + else -> OnLeave.Save(accepted) + } + } + + /** + * What the field says under [typed], or null when it is an address we accept. Decided by what the + * field holds NOW, not by what leaving would do (the review of A44): an address we would not accept + * that an older build stored is kept on leaving without a message, since the user did not type it + * here, but every relay call uses it, so the field still says so. + */ + fun fieldError(typed: String, stored: String): String? { + if (accept(typed) != null) return null + return if (onLeave(typed, stored) == OnLeave.Rejected) NOT_SAVED else STORED_UNUSABLE + } + + /** Under an address the user typed that we refuse. */ + const val NOT_SAVED = "Not a full https:// address: it will not be saved." + + /** Under a stored address we refuse (only an older build could have stored it). */ + const val STORED_UNUSABLE = "Not a full https:// address: the relay can't be reached with it. Correct it or reset to default." + + /** The form the app stores an address in (HostStore's setter trims the same way). */ + private fun normalize(address: String): String = address.trim().trimEnd('/') +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/Framing.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/Framing.kt new file mode 100644 index 0000000000..0cac4befbf --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/Framing.kt @@ -0,0 +1,113 @@ +package dev.nodeterm.protocol.relay + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * `src/main/remote/framing.ts`: the terminal-stream binary frame the standing phone host speaks. + * + * Header (16 bytes, little-endian): kind 0x74 · version 1 · opcode · reserved · streamId u32 · + * seq high u32 · seq low u32, then the raw payload. Opcode numbers are a stable wire contract. + */ +object Op { + const val OUTPUT = 1 + const val SNAPSHOT_START = 2 + const val SNAPSHOT_CHUNK = 3 + const val SNAPSHOT_END = 4 + const val RESIZED = 5 + const val ERROR = 6 + const val INPUT = 7 + const val RESIZE = 8 + const val SUBSCRIBE = 9 + const val UNSUBSCRIBE = 10 + const val SNAPSHOT_REQUEST = 11 + + private val KNOWN = setOf( + OUTPUT, SNAPSHOT_START, SNAPSHOT_CHUNK, SNAPSHOT_END, RESIZED, ERROR, INPUT, RESIZE, + SUBSCRIBE, UNSUBSCRIBE, SNAPSHOT_REQUEST + ) + + fun isKnown(op: Int): Boolean = op in KNOWN +} + +class Frame(val op: Int, val streamId: Long, val seq: Long, val payload: ByteArray) + +object Framing { + private const val STREAM_KIND = 0x74 + private const val STREAM_VERSION = 1 + const val HEADER_BYTES = 16 + + fun encode(op: Int, streamId: Long, seq: Long, payload: ByteArray): ByteArray { + val out = ByteBuffer.allocate(HEADER_BYTES + payload.size).order(ByteOrder.LITTLE_ENDIAN) + out.put(STREAM_KIND.toByte()) + out.put(STREAM_VERSION.toByte()) + out.put(op.toByte()) + out.put(0) + out.putInt(streamId.toInt()) + val s = maxOf(0L, seq) + out.putInt((s ushr 32).toInt()) + out.putInt(s.toInt()) + out.put(payload) + return out.array() + } + + /** Null for a short buffer, a bad kind/version, or an unknown opcode — never throws. */ + fun decode(buf: ByteArray): Frame? { + if (buf.size < HEADER_BYTES) return null + val b = ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN) + if ((b.get(0).toInt() and 0xff) != STREAM_KIND || (b.get(1).toInt() and 0xff) != STREAM_VERSION) return null + val op = b.get(2).toInt() and 0xff + if (!Op.isKnown(op)) return null + val streamId = b.getInt(4).toLong() and 0xffffffffL + val high = b.getInt(8).toLong() and 0xffffffffL + val low = b.getInt(12).toLong() and 0xffffffffL + return Frame(op, streamId, (high shl 32) or low, buf.copyOfRange(HEADER_BYTES, buf.size)) + } + + /** `OP.Resize` / `OP.Resized` payload: two uint16 LE, cols then rows. */ + fun sizePayload(cols: Int, rows: Int): ByteArray = + ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN) + .putShort(cols.coerceIn(1, 0xffff).toShort()) + .putShort(rows.coerceIn(1, 0xffff).toShort()) + .array() + + fun readSize(payload: ByteArray): Pair? { + if (payload.size < 4) return null + val b = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN) + return (b.getShort(0).toInt() and 0xffff) to (b.getShort(2).toInt() and 0xffff) + } +} + +/** + * `snapshot.ts`: the current-screen paint arrives as Start · Chunk* · End on the stream. Chunk + * BYTES are accumulated and decoded only on End, so a multi-byte code point split across a chunk + * boundary survives. + */ +class SnapshotReassembler { + private val chunks = ArrayList() + private var open = false + + /** Returns the reassembled text on End, else null. */ + fun accept(frame: Frame): String? { + when (frame.op) { + Op.SNAPSHOT_START -> { + chunks.clear() + open = true + } + Op.SNAPSHOT_CHUNK -> if (open && frame.payload.isNotEmpty()) chunks.add(frame.payload) + Op.SNAPSHOT_END -> { + val total = chunks.sumOf { it.size } + val merged = ByteArray(total) + var off = 0 + for (c in chunks) { + System.arraycopy(c, 0, merged, off, c.size) + off += c.size + } + chunks.clear() + open = false + return String(merged, Charsets.UTF_8) + } + } + return null + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/OkHttpRelayTransport.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/OkHttpRelayTransport.kt new file mode 100644 index 0000000000..3b758b667d --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/OkHttpRelayTransport.kt @@ -0,0 +1,56 @@ +package dev.nodeterm.protocol.relay + +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import okio.ByteString.Companion.toByteString +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The production [RelayTransport]: an OkHttp WebSocket. Text frames stay text and binary frames + * stay binary end to end — the relay preserves the distinction and the handshake depends on it. + * OkHttp queues sends issued before the upgrade completes and delivers messages on one reader + * thread in wire order, which is the ordering [RelaySocket] relies on. + */ +class OkHttpRelayTransport private constructor(private val ws: WebSocket) : RelayTransport { + override fun sendText(text: String): Boolean = ws.send(text) + override fun sendBinary(bytes: ByteArray): Boolean = ws.send(bytes.toByteString()) + override fun queuedBytes(): Long = ws.queueSize() + override fun close() { + if (!ws.close(1000, null)) ws.cancel() + } + + companion object { + val defaultClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) + // Transport-level liveness on top of the E2EE keepalive: a phone that roams between + // networks otherwise holds a dead socket until the OS gives up on it. + .pingInterval(20, TimeUnit.SECONDS) + .build() + } + + fun factory(client: OkHttpClient = defaultClient) = RelayTransportFactory { url, events -> + val closed = AtomicBoolean(false) + val fireClosed = { reason: String? -> if (closed.compareAndSet(false, true)) events.onClosed(reason) } + val ws = client.newWebSocket(Request.Builder().url(url).build(), object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) = events.onText(text) + override fun onMessage(webSocket: WebSocket, bytes: ByteString) = events.onBinary(bytes.toByteArray()) + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(1000, null) + fireClosed(reason.ifEmpty { "closed ($code)" }) + } + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = + fireClosed(reason.ifEmpty { "closed ($code)" }) + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = + fireClosed(response?.let { "HTTP ${it.code}" } ?: t.message ?: t.javaClass.simpleName) + }) + OkHttpRelayTransport(ws) + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelayApi.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelayApi.kt new file mode 100644 index 0000000000..7c42d26f88 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelayApi.kt @@ -0,0 +1,143 @@ +package dev.nodeterm.protocol.relay + +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.l +import dev.nodeterm.protocol.model.J.s +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resumeWithException + +class RelayApiException(message: String, val status: Int? = null) : Exception(message) + +data class RelayJoin(val pairingToken: String, val exp: Long) + +data class RelayDevice(val deviceToken: String, val hostId: String, val exp: Long) + +/** + * The two backend calls a phone makes for the relay leg (`api.nodeterm.dev`, a separate repo): + * + * - `POST /v1/relay/join` — trade the device token minted at pairing for a SINGLE-USE, short-lived + * (~120 s) relay token for this host's room. A new one per connect: relay-socket never re-dials + * on an old token (standing-host.ts manual smoke test, step 3; docs/ios-protocol-migration.md §8.5). + * - `POST /v1/relay/device` — the free-tier TOFU mint, for LATE ADOPTION: a phone paired while + * remote access was off reads `~/.nodeterm/relay.json` over its SSH bootstrap and mints its own + * device token (src/main/remote/relay-advertise.ts). Same body the desktop sends for a free mint. + * + * The join body/response shape is not documented in this repo (the backend lives elsewhere); it is + * read tolerantly — `pairingToken`, `token` or `joinToken` — and every failure says what happened. + */ +class RelayApi( + val apiBase: String = DEFAULT_API_BASE, + private val client: OkHttpClient = defaultHttpClient, + /** Bounds the entire request, including a response body that keeps trickling bytes. */ + private val deadlineMs: Long = DEFAULT_DEADLINE_MS +) { + init { + require(deadlineMs > 0) { "the relay HTTP deadline must be positive" } + } + + suspend fun join(deviceToken: String, hostId: String): RelayJoin { + val body = post("/v1/relay/join", buildJsonObject { + put("deviceToken", deviceToken) + put("hostId", hostId) + }) + val token = body.s("pairingToken") ?: body.s("token") ?: body.s("joinToken") + ?: throw RelayApiException("The relay did not hand out a connection token.") + return RelayJoin(token, body.l("exp") ?: 0) + } + + suspend fun mintDevice( + deviceId: String, + hostDeviceId: String, + hostPublicKeyB64: String, + label: String, + priorDeviceToken: String? + ): RelayDevice { + val body = post("/v1/relay/device", buildJsonObject { + put("deviceId", deviceId) + put("hostDeviceId", hostDeviceId) + put("hostPublicKeyB64", hostPublicKeyB64) + put("label", label) + priorDeviceToken?.let { put("priorDeviceToken", it) } + }) + val token = body.s("deviceToken") ?: throw RelayApiException("The relay did not mint a device token.") + return RelayDevice(token, body.s("hostId") ?: "", body.l("exp") ?: 0) + } + + private suspend fun post(path: String, json: JsonObject): JsonObject = + // OkHttp's call timeout begins after Dispatcher queueing. Start the phone's deadline here + // so a busy dispatcher cannot leave a token request waiting indefinitely before it runs. + withTimeoutOrNull(deadlineMs) { enqueuePost(path, json) } + ?: throw unreachable(IOException("request timed out")) + + private suspend fun enqueuePost(path: String, json: JsonObject): JsonObject = suspendCancellableCoroutine { cont -> + val req = Request.Builder() + .url(apiBase.trimEnd('/') + path) + .post(json.toString().toRequestBody(JSON)) + .build() + val call = client.newCall(req) + // The WebSocket client deliberately has no read deadline. HTTP needs its own finite one, + // including when a caller supplies a client: otherwise roaming can wedge token minting. + call.timeout().timeout(deadlineMs, TimeUnit.MILLISECONDS) + cont.invokeOnCancellation { call.cancel() } + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + cont.resumeWithException(unreachable(e)) + } + + override fun onResponse(call: Call, response: Response) { + // Keep cancellation tied to the call until the body has been consumed and closed. + // Reading in OkHttp's callback also keeps blocking I/O off the phone's main thread. + cont.resumeWith(runCatching { + try { + response.use { res -> + val text = res.body?.string().orEmpty() + if (!res.isSuccessful) { + val msg = J.obj(J.parse(text))?.let { it.s("error") ?: it.s("message") } + throw RelayApiException( + "The relay refused the request (HTTP ${res.code})" + (msg?.let { ": $it" } ?: "."), + res.code + ) + } + J.obj(J.parse(text)) ?: throw RelayApiException("The relay answered with something that is not JSON.") + } + } catch (e: IOException) { + throw unreachable(e) + } + }) + } + }) + } + + private fun unreachable(e: IOException) = + RelayApiException("Couldn't reach the relay: ${e.message ?: e.javaClass.simpleName}") + + companion object { + const val DEFAULT_API_BASE = "https://api.nodeterm.dev" + const val DEFAULT_RELAY_URL = "wss://relay.nodeterm.dev" + const val DEFAULT_DEADLINE_MS = 30_000L + + /** Finite HTTP timeouts; the long-lived WebSocket keeps its separate client. */ + internal val defaultHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .writeTimeout(20, TimeUnit.SECONDS) + .callTimeout(DEFAULT_DEADLINE_MS, TimeUnit.MILLISECONDS) + .build() + } + private val JSON = "application/json".toMediaType() + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelaySocket.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelaySocket.kt new file mode 100644 index 0000000000..05103d7f3b --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/relay/RelaySocket.kt @@ -0,0 +1,362 @@ +package dev.nodeterm.protocol.relay + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.crypto.E2ee +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** A duplex relay connection: text frames carry the plaintext handshake, binary frames E2EE boxes. */ +interface RelayTransport { + fun sendText(text: String): Boolean + fun sendBinary(bytes: ByteArray): Boolean + /** Bytes handed to the socket and not yet flushed — the backpressure signal. */ + fun queuedBytes(): Long + fun close() +} + +/** Inbound side of a [RelayTransport]. The transport must call these in wire order, one at a time. */ +interface RelayTransportEvents { + fun onText(text: String) + fun onBinary(bytes: ByteArray) + /** Fired at most once, on a close OR an error. */ + fun onClosed(reason: String?) +} + +fun interface RelayTransportFactory { + fun open(url: String, events: RelayTransportEvents): RelayTransport +} + +/** What an application layer sees of a live relay session. */ +interface RelaySocketListener { + /** The E2EE handshake completed. `sas` is the 6-digit code the desktop shows beside the approval. */ + fun onReady(sas: String) {} + /** A one-way host → phone notification (`canvas:state` today). */ + fun onNotify(method: String, params: JsonElement?) {} + /** A decoded terminal frame. */ + fun onFrame(frame: Frame) {} + /** The connection dropped on its own (never fired for [RelaySocket.close]). */ + fun onClosed(reason: String?) {} +} + +open class RpcException(message: String) : Exception(message) + +/** + * The request WENT OUT and no answer came: it timed out, or the socket closed while it waited. The + * host may have acted on it (or still be acting), unlike a request that was never sent or one the + * host answered with an error, which are plain [RpcException]s. + */ +class RpcUnansweredException(message: String) : RpcException(message) + +/** + * The CLIENT (phone) role of `src/main/remote/relay-socket.ts`, rule for rule: + * + * - the token rides the URL as `?token=` (never a data frame); + * - handshake `e2ee_hello → e2ee_ready → e2ee_auth → e2ee_authenticated`; base key = + * `box.before(hostPub, ourSecret)`, session key = HKDF(base, hostNonce ‖ clientNonce); + * - every box's sealed plaintext is `[role][seq u64 as two LE u32, high then low][tag][body]`; + * only boxes carrying the HOST role (1) are accepted (a box with our own role is a relay + * reflection) and only with a strictly increasing seq (replay/reorder); + * - once ready, a plaintext handshake frame is IGNORED — never re-keyed, never a reason to close + * (docs/ios-protocol-migration.md §2.4); + * - the host key is pinned: the one passed in is the only identity this session may use. + * + * Reconnect is the caller's job: a relay token is single-use, so re-dialing here with the same one + * would only be refused. Callers mint a fresh token and build a new socket. + */ +class RelaySocket( + url: String, + token: String, + private val ourKeys: BoxKeyPair, + private val hostPublicKeyB64: String, + transportFactory: RelayTransportFactory, + private val listener: RelaySocketListener, + private val scheduler: ScheduledExecutorService = sharedScheduler +) { + private enum class State { HANDSHAKING, READY, CLOSED } + + private class Pending(val onResult: (Result) -> Unit, val timeout: ScheduledFuture<*>?) + + private val lock = Any() + private var state = State.HANDSHAKING + private var intentionallyClosed = false + private val baseKey: ByteArray = E2ee.deriveSharedKey(hostPublicKeyB64, ourKeys.secretKey) + private val ourNonce: ByteArray = E2ee.randomSessionNonce() + private var sessionKey: ByteArray? = null + private var sendSeq = 0L + private var recvSeq = -1L + private var readyFired = false + private var requestCounter = 0 + private val pending = HashMap() + private var keepalive: ScheduledFuture<*>? = null + private val transport: RelayTransport + + /** The SAS for this channel. Derived from the pinned host key, so it is known before `ready`. */ + val sas: String = E2ee.sasFromSharedKey(baseKey) + + init { + val events = object : RelayTransportEvents { + override fun onText(text: String) = handleControl(text) + override fun onBinary(bytes: ByteArray) = handleBinary(bytes) + override fun onClosed(reason: String?) = handleClose(reason) + } + transport = transportFactory.open(withToken(url, token), events) + val hello = buildJsonObject { + put("type", "e2ee_hello") + put("publicKeyB64", ourKeys.publicKeyB64) + put("nonceB64", B64.encode(ourNonce)) + } + transport.sendText(hello.toString()) + } + + val isReady: Boolean get() = synchronized(lock) { state == State.READY } + + // ---- inbound -------------------------------------------------------------------------- + + private fun handleControl(raw: String) { + synchronized(lock) { + // SECURITY: after ready (or once a session key exists) a plaintext control frame is a + // re-key attempt. Drop it without re-keying and without closing. + if (readyFired || sessionKey != null || state == State.CLOSED) return + val msg = parseObject(raw) ?: return + if (msg.str("type") != "e2ee_ready") return + val hostNonce = msg.str("nonceB64")?.let(B64::decode) ?: return + if (hostNonce.size != 16) return + val key = E2ee.deriveSessionKey(baseKey, hostNonce, ourNonce) + sessionKey = key + sendSealedLocked(TAG_RPC, """{"type":"e2ee_auth"}""".toByteArray()) + } + } + + private fun handleBinary(bytes: ByteArray) { + val effects = ArrayList<() -> Unit>() + synchronized(lock) { + if (state == State.CLOSED) return + val key = sessionKey ?: return + val sealed = E2ee.decrypt(bytes, key) ?: return + if (sealed.size < HEADER_BYTES) return + if (sealed[0].toInt() != HOST_ROLE) return + val b = ByteBuffer.wrap(sealed).order(ByteOrder.LITTLE_ENDIAN) + val seq = ((b.getInt(1).toLong() and 0xffffffffL) shl 32) or (b.getInt(5).toLong() and 0xffffffffL) + if (seq <= recvSeq) return + recvSeq = seq + if (sealed.size < HEADER_BYTES + 1) return + val tag = sealed[HEADER_BYTES].toInt() and 0xff + val body = sealed.copyOfRange(HEADER_BYTES + 1, sealed.size) + if (state == State.HANDSHAKING) { + if (tag != TAG_RPC) return + val msg = parseObject(String(body, Charsets.UTF_8)) ?: return + if (msg.str("type") == "e2ee_authenticated") { + state = State.READY + readyFired = true + startKeepaliveLocked() + effects += { listener.onReady(sas) } + } + return@synchronized + } + when (tag) { + TAG_FRAME -> Framing.decode(body)?.let { f -> effects += { listener.onFrame(f) } } + TAG_RPC -> handleEnvelopeLocked(String(body, Charsets.UTF_8), effects) + // TAG_TUNNEL_* belong to the rpc.ts dialect the standing phone host does not speak. + else -> Unit + } + } + for (e in effects) e() + } + + private fun handleEnvelopeLocked(raw: String, effects: MutableList<() -> Unit>) { + val env = parseObject(raw) ?: return + when (env.str("kind")) { + "res" -> { + val id = env.str("id") ?: return + val waiter = pending.remove(id) ?: return + waiter.timeout?.cancel(false) + val ok = (env["ok"] as? JsonPrimitive)?.contentOrNull == "true" + val body = env["body"] + val result = if (ok) Result.success(body) else Result.failure(RpcException(errorMessage(body))) + effects += { waiter.onResult(result) } + } + "notify" -> { + val method = env.str("method") ?: return + val params = env["params"] + effects += { listener.onNotify(method, params) } + } + // "keepalive" and host→client "req" (the legacy dialect never sends one) are ignored. + else -> Unit + } + } + + private fun handleClose(reason: String?) { + val waiters: List + val notify: Boolean + synchronized(lock) { + if (state == State.CLOSED) return + state = State.CLOSED + keepalive?.cancel(false) + keepalive = null + sessionKey = null + waiters = pending.values.toList() + pending.clear() + notify = !intentionallyClosed + } + for (w in waiters) { + w.timeout?.cancel(false) + w.onResult(Result.failure(RpcUnansweredException("Relay connection closed."))) + } + if (notify) listener.onClosed(reason) + } + + // ---- outbound ------------------------------------------------------------------------- + + private fun sendSealedLocked(tag: Int, body: ByteArray): Boolean { + val key = sessionKey ?: return false + val seq = sendSeq++ + val plain = ByteBuffer.allocate(HEADER_BYTES + 1 + body.size).order(ByteOrder.LITTLE_ENDIAN) + plain.put(CLIENT_ROLE.toByte()) + plain.putInt((seq ushr 32).toInt()) + plain.putInt(seq.toInt()) + plain.put(tag.toByte()) + plain.put(body) + return transport.sendBinary(E2ee.encrypt(plain.array(), key)) + } + + private fun startKeepaliveLocked() { + keepalive?.cancel(false) + keepalive = scheduler.scheduleWithFixedDelay({ + synchronized(lock) { + if (state == State.READY) sendSealedLocked(TAG_RPC, """{"kind":"keepalive"}""".toByteArray()) + } + }, KEEPALIVE_MS, KEEPALIVE_MS, TimeUnit.MILLISECONDS) + } + + /** + * Send an RPC request. `onResult` runs on the transport's reader thread for a response — + * BEFORE any later message is processed, which is what lets `pty.attach` register its stream + * before the snapshot frames that follow the response arrive — or on a timer / closing thread + * for a timeout or a closed socket. + */ + fun request( + method: String, + params: JsonElement? = null, + timeoutMs: Long = RPC_TIMEOUT_MS, + onResult: (Result) -> Unit + ) { + val failure: RpcException? + synchronized(lock) { + if (state != State.READY) { + failure = RpcException("Relay socket is not connected.") + } else { + requestCounter += 1 + val id = "client-rpc-$requestCounter-${System.currentTimeMillis()}" + val timer = scheduler.schedule({ + val w = synchronized(lock) { pending.remove(id) } + w?.onResult?.invoke(Result.failure(RpcUnansweredException("RPC timed out: $method"))) + }, timeoutMs, TimeUnit.MILLISECONDS) + pending[id] = Pending(onResult, timer) + val env = buildJsonObject { + put("kind", "req") + put("id", id) + put("method", method) + put("params", params ?: JsonObject(emptyMap())) + } + if (sendSealedLocked(TAG_RPC, env.toString().toByteArray(Charsets.UTF_8))) { + failure = null + } else { + pending.remove(id) + timer.cancel(false) + failure = RpcException("Relay socket is not connected.") + } + } + } + if (failure != null) onResult(Result.failure(failure)) + } + + suspend fun call(method: String, params: JsonElement? = null, timeoutMs: Long = RPC_TIMEOUT_MS): JsonElement? = + suspendCancellableCoroutine { cont -> + request(method, params, timeoutMs) { r -> + r.fold({ cont.resume(it) }, { cont.resumeWithException(it) }) + } + } + + /** Send a terminal frame. False when not ready or over the buffered-bytes ceiling. */ + fun sendFrame(op: Int, streamId: Long, seq: Long, payload: ByteArray): Boolean = synchronized(lock) { + if (state != State.READY) return false + if (transport.queuedBytes() > MAX_BINARY_BUFFERED_BYTES) return false + sendSealedLocked(TAG_FRAME, Framing.encode(op, streamId, seq, payload)) + } + + /** Intentional, final close: pending requests fail, [RelaySocketListener.onClosed] is NOT fired. */ + fun close() { + synchronized(lock) { + if (state == State.CLOSED) return + intentionallyClosed = true + } + handleClose("closed") + transport.close() + } + + private fun parseObject(raw: String): JsonObject? = try { + Json.parseToJsonElement(raw) as? JsonObject + } catch (_: Exception) { + null + } + + private fun JsonObject.str(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + + private fun errorMessage(body: JsonElement?): String { + val o = body as? JsonObject ?: return "RPC failed." + return o.str("message") ?: o.str("error") ?: "RPC failed." + } + + companion object { + const val HOST_ROLE = 1 + const val CLIENT_ROLE = 2 + const val HEADER_BYTES = 9 + const val TAG_RPC = 0x01 + const val TAG_FRAME = 0x02 + const val RPC_TIMEOUT_MS = 30_000L + const val KEEPALIVE_MS = 25_000L + const val MAX_BINARY_BUFFERED_BYTES = 8L * 1024 * 1024 + + val sharedScheduler: ScheduledExecutorService by lazy { + Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "nodeterm-relay-timer").apply { isDaemon = true } } + } + + /** `?token=` exactly as `encodeURIComponent` would write it (`&` when a query already exists). */ + fun withToken(url: String, token: String): String { + val sep = if (url.contains('?')) '&' else '?' + return "$url${sep}token=${encodeUriComponent(token)}" + } + + fun encodeUriComponent(s: String): String { + val unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()" + val sb = StringBuilder() + for (b in s.toByteArray(Charsets.UTF_8)) { + val c = (b.toInt() and 0xff).toChar() + if (b >= 0 && unreserved.indexOf(c) >= 0) sb.append(c) + else sb.append('%').append("0123456789ABCDEF"[(b.toInt() shr 4) and 0xf]).append("0123456789ABCDEF"[b.toInt() and 0xf]) + } + return sb.toString() + } + + internal val JsonElement?.isJsonNull: Boolean get() = this == null || this is JsonNull + internal fun JsonElement?.stringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content + internal fun JsonElement?.primitiveContent(): String? = (this as? JsonPrimitive)?.jsonPrimitive?.contentOrNull + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/PhoneIdentity.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/PhoneIdentity.kt new file mode 100644 index 0000000000..2fad458fc7 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/PhoneIdentity.kt @@ -0,0 +1,70 @@ +package dev.nodeterm.protocol.secure + +import java.util.UUID + +/** Where the phone's public identity values are kept (HostStore's preferences on Android). */ +interface PlainStorage { + fun get(name: String): String? + /** [durable] = do not return before the change is on disk. */ + fun put(name: String, value: String, durable: Boolean) + fun remove(name: String, durable: Boolean) +} + +/** + * The phone's relay identity: the NaCl box secret every paired desktop pins, and the `deviceId` the + * relay backend keys this phone's device row on (`/v1/relay/device` upserts on it). The two belong + * together, and this class keeps them together (audit A51): **a deviceId never outlives the box + * secret it was minted with.** + * + * The box secret is sealed under an Android Keystore key, so it cannot follow the phone's data to + * another device, and it is replaced when it is provably lost ([SecretStoreCore]). The deviceId is a + * plain preference and could follow (an Android 12+ device-to-device transfer ignores + * `allowBackup="false"`; the manifest's data extraction rules now stop that, and this rule does not + * depend on them). A phone that re-paired with an OLD deviceId, NEW keys and no readable device + * token would be re-registering a row it cannot prove it owns: per the desktop's note on + * `priorDeviceToken` (src/main/pairing-service.ts; the backend is not in this repo), the free-tier + * re-registration of a known deviceId asks for the previous device token and can refuse without it, + * which leaves the pairing LAN-only (and fails it outright on a relay-only Windows desktop). It + * would also share one row with the phone it was copied from, so removing either pairing on an + * entitled desktop would revoke both phones. A new deviceId is a first registration instead. + * + * The rule: when the box secret has to be created (absent, or provably lost), the stored deviceId + * is removed first, durably, and only then is the new secret written; and [deviceId] resolves the + * box secret before it reads or mints an id. A process that dies between the two steps leaves no + * deviceId, never an old one beside a new key. + */ +class PhoneIdentity( + private val secrets: SecretStoreCore, + private val plain: PlainStorage, + private val newDeviceId: () -> String = { UUID.randomUUID().toString() } +) { + private var box: ByteArray? = null + + /** The box secret key (32 bytes), created on first use. See the class comment for the rule. */ + @Synchronized + fun boxSecret(): ByteArray { + box?.let { return it.copyOf() } + val bytes = secrets.getOrCreate32(BOX_SECRET, beforeCreate = { plain.remove(DEVICE_ID, durable = true) }) + box = bytes + return bytes.copyOf() + } + + /** + * The phone's relay deviceId, minted on first use. The box secret is resolved first, so an id is + * only ever handed out beside the key it belongs to. Throws [SecretUnavailableException] when the + * box secret cannot be read right now (nothing is changed then). + */ + @Synchronized + fun deviceId(): String { + boxSecret() + plain.get(DEVICE_ID)?.takeIf { it.isNotBlank() }?.let { return it } + return newDeviceId().also { plain.put(DEVICE_ID, it, durable = true) } + } + + companion object { + /** In the sealed store (`nodeterm.secure`). */ + const val BOX_SECRET = "box.secret" + /** In the plain store (`nodeterm.hosts`), the key earlier builds already wrote. */ + const val DEVICE_ID = "deviceId" + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt new file mode 100644 index 0000000000..2dab5d20ec --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/secure/SecretStoreCore.kt @@ -0,0 +1,128 @@ +package dev.nodeterm.protocol.secure + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.AEADBadTagException + +/** Where sealed values are kept (app-private preferences on Android). */ +interface SecretStorage { + fun get(name: String): String? + /** [durable] = do not return before the value is on disk (the phone's identity secrets). */ + fun put(name: String, value: String, durable: Boolean) + fun remove(name: String) +} + +/** Seals and opens values (AES-GCM under an Android Keystore key). [open] throws on failure. */ +interface Sealer { + fun seal(plain: ByteArray): ByteArray + fun open(sealed: ByteArray): ByteArray +} + +/** + * A stored secret cannot be read RIGHT NOW (the keystore is busy, locked, or threw something we do + * not recognise). It is not evidence the value is gone, so nothing is overwritten; try again later. + */ +class SecretUnavailableException(name: String, cause: Throwable) : + Exception("A stored secret ($name) can't be read right now: ${cause.message ?: cause.javaClass.simpleName}", cause) + +/** + * The phone's secrets at rest, with one rule the first version got wrong (audit A24): a value is + * replaced only on POSITIVE evidence it can never be read again. That version turned ANY decrypt + * error into "absent", and `getOrCreate32` then wrote fresh random bytes over the phone's relay and + * SSH identities — after which every paired computer had to approve the phone again (someone at + * the desk) and direct SSH failed until a re-pair. + * + * Positive evidence is: nothing stored; a stored blob that is malformed (not base64, or too short to + * hold a nonce and a tag); or the cipher's own authentication failing (`AEADBadTagException` — the + * key that sealed it is gone). Anything else is [SecretUnavailableException], and nothing is written. + */ +class SecretStoreCore(private val storage: SecretStorage, private val sealer: Sealer) { + sealed interface Read { + data object Absent : Read + class Value(val bytes: ByteArray) : Read + /** Stored, but provably never readable again. */ + data class Lost(val reason: String) : Read + } + + private val _revision = MutableStateFlow(0L) + + /** + * Bumped by every write and removal (never by a read), so a screen that shows [contains] can ask + * again when a secret comes or goes — a relay token minted by a late adoption, one dropped when + * its computer is forgotten — even when nothing else it shows has changed (audit A47). + */ + val revision: StateFlow = _revision.asStateFlow() + + /** + * Whether a value is STORED under [name], from the storage alone: nothing is opened, so no + * Keystore round trip, and this store's lock is not taken, so the answer never waits behind a + * decrypt another thread is in the middle of (audit A47 — the host list asked this per row, per + * recomposition, on the main thread, by decrypting the relay token). "Stored" is what a label + * needs: a value that cannot be read right now still counts (the keystore will answer later), + * and so does one the Keystore can no longer open at all (its key was lost), which only opening + * it can tell. Anything that USES the value reads it with [getBytes]. + */ + fun contains(name: String): Boolean = storage.get(name) != null + + @Synchronized + fun read(name: String): Read { + val raw = storage.get(name) ?: return Read.Absent + val sealed = try { + Base64.getDecoder().decode(raw) + } catch (_: IllegalArgumentException) { + return Read.Lost("not base64") + } + if (sealed.size < MIN_SEALED_BYTES) return Read.Lost("too short") + return try { + Read.Value(sealer.open(sealed)) + } catch (_: AEADBadTagException) { + Read.Lost("authentication failed") + } catch (e: Exception) { + throw SecretUnavailableException(name, e) + } + } + + /** The value, or null when absent or lost. Throws [SecretUnavailableException] when unreadable NOW. */ + fun getBytes(name: String): ByteArray? = (read(name) as? Read.Value)?.bytes + + @Synchronized + fun putBytes(name: String, value: ByteArray, durable: Boolean = false) { + storage.put(name, Base64.getEncoder().encodeToString(sealer.seal(value)), durable) + _revision.update { it + 1 } + } + + @Synchronized + fun remove(name: String) { + storage.remove(name) + _revision.update { it + 1 } + } + + /** + * Get-or-create 32 random bytes under [name] (the box secret, the SSH seed). Creates only when + * nothing is stored or the stored value is provably lost; the first write is durable. + * + * [beforeCreate] runs, under this store's lock, only when a new value is about to be written, + * and before it is: what belonged to the old value can be dropped first ([PhoneIdentity] drops + * the relay deviceId). If it throws, nothing is created. + */ + @Synchronized + fun getOrCreate32(name: String, beforeCreate: () -> Unit = {}): ByteArray { + when (val r = read(name)) { + is Read.Value -> if (r.bytes.size == 32) return r.bytes + Read.Absent, is Read.Lost -> Unit + } + beforeCreate() + val fresh = ByteArray(32).also { SecureRandom().nextBytes(it) } + putBytes(name, fresh, durable = true) + return fresh + } + + companion object { + /** A 12-byte GCM nonce plus a 16-byte tag: anything shorter was never a sealed value. */ + const val MIN_SEALED_BYTES = 12 + 16 + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/HostBrowse.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/HostBrowse.kt new file mode 100644 index 0000000000..39fbe1ff9c --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/HostBrowse.kt @@ -0,0 +1,280 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.model.AgentNodeStatus +import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxNodeNow +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.model.MirrorInbox +import dev.nodeterm.protocol.model.NodeInfo +import dev.nodeterm.protocol.model.NodeKind +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.TmuxNames +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * What [SshScripts.browse] printed, and the listing it makes (audit A27). + * + * A computer the phone SSHes into can hold two kinds of nodeterm sessions at once: those of a nodeterm + * running ON it (the desktop app or the Server Edition: `workspace.json` + `agent-status.json` in its + * data dir, sessions on `node-terminal`), and those of a desktop ELSEWHERE that drives it over SSH + * (sessions on `nodeterm-rmt`). For the second kind there is no workspace.json here at all; what that + * desktop leaves on this computer is: + * + * - its SSH project's canvas, `/.nodeterm/project.json` (the desktop's SSH mirror writes + * it with the desktop's project id as `id`; see `projectToFile` in src/core/workspace-files.ts), + * - one status slice per connected SSH project, `~/.nodeterm/agent-status-.json` + * (src/main/remote-ssh/remote-status-push.ts), refreshed at least every [STATUS_HEARTBEAT_MS] + * while that desktop is connected, so an older one is NO DATA ([SLICE_STALE_MS]), + * - the sessions themselves. + * + * [assemble] turns those into [ProjectInfo.drivenRemotely] projects next to the host's own ones. The + * host's own index always wins: a project or node it already lists is never listed twice, and a node + * of the paired desktop's OWN SSH projects stays the desktop's (audit A09). + */ +object HostBrowse { + /** `STATUS_HEARTBEAT_MS` in src/main/remote-ssh/remote-status-push.ts (pinned by a test). */ + const val STATUS_HEARTBEAT_MS = 60_000L + + /** + * A slice whose `updatedAt` is older than this is no data: the desktop that wrote it disconnected + * or quit, and its states are whatever they were then. Twice the heartbeat, so one late push does + * not blank a connected desktop's sessions. Measured against this phone's clock: the slice carries + * the desktop's clock, and the two are both network-synced in the setups this serves. + */ + const val SLICE_STALE_MS = 2 * STATUS_HEARTBEAT_MS + + /** A project id as the desktop mints it (`[A-Za-z0-9._-]`, src/shared/project-id.ts). */ + private val PROJECT_ID = Regex("^[A-Za-z0-9._-]{1,200}$") + + /** The id of the one synthetic project that holds `nodeterm-rmt` sessions no project names. */ + const val OTHER_SESSIONS_ID = "host:other-sessions" + const val OTHER_SESSIONS_NAME = "Other sessions on this computer" + + data class Output( + /** True when the meta block was printed at all (an older or broken script prints none). */ + val metaSeen: Boolean, + /** The data dir of a nodeterm running on this computer, or null when none was found. */ + val userData: String?, + /** The part shaped like the relay's `projects.list` blob ([ProjectsParser.parseBlob]). */ + val blob: String, + /** `nt-*` session names on `nodeterm-rmt`. */ + val rmtSessions: List, + /** projectId → the slice file's text. */ + val slices: List>, + /** A folder holding `.nodeterm/project.json` → that file's text. */ + val projectFiles: List>, + /** + * Whether the computer advertises its relay right now (`~/.nodeterm/relay.json`, present + * only while the desktop's phone host is registered at the relay; audit A26), or null when + * the meta block did not say. + */ + val relayAdvertised: Boolean? = null, + /** Plain shells with validated atomic ownership metadata on the independent phone socket. */ + val phoneTerminals: List = emptyList() + ) { + /** + * Nothing of nodeterm's was found as of [now]: no data dir, no session a desktop runs here, and + * no slice that is still data ([freshSlices]). Not "no nodeterm here": a Server Edition started + * with another `--data-dir` looks like this. + * + * Judged on what the listing can USE, not on what files exist: the desktop never deletes a + * slice (remote-status-push.ts only writes), so on a computer a desktop drove once the stale + * ones stay for good, and counting them made such a computer read as an empty one forever + * (the review of A27a). + */ + fun nothingFound(now: Long): Boolean = + metaSeen && userData == null && rmtSessions.isEmpty() && phoneTerminals.isEmpty() && freshSlices(this, now).isEmpty() + } + + /** Split [SshScripts.browse]'s output into its sections. Every section is best-effort. */ + fun split(raw: String): Output { + val metaEnd = raw.indexOf(SshScripts.META_END) + val meta = if (metaEnd >= 0) raw.substring(0, metaEnd) else "" + val rest = if (metaEnd >= 0) raw.substring(metaEnd + SshScripts.META_END.length).removePrefix("\n") else raw + val ud = meta.lineSequence().firstOrNull { it.startsWith("ud=") }?.removePrefix("ud=")?.takeIf { it.isNotBlank() } + val relay = when (meta.lineSequence().firstOrNull { it.startsWith("relay=") }?.removePrefix("relay=")?.trim()) { + "1" -> true + "0" -> false + else -> null + } + + val blob = StringBuilder() + val rmt = ArrayList() + val slices = ArrayList>() + val files = ArrayList>() + val phone = ArrayList() + var section: String? = null // null = the blob; "rmt"; "slice"; "file"; "end" + var key = "" + val body = StringBuilder() + fun close() { + when (section) { + "slice" -> slices += key to body.toString() + "file" -> files += key to body.toString() + } + body.setLength(0) + } + for (line in rest.split('\n')) { + when { + line == SshScripts.RMT_MARK -> { close(); section = "rmt" } + line == SshScripts.PHONE_MARK -> { close(); section = "phone" } + line.startsWith(SshScripts.SLICE_MARK) -> { close(); section = "slice"; key = line.removePrefix(SshScripts.SLICE_MARK) } + line.startsWith(SshScripts.PROJECT_FILE_MARK) -> { close(); section = "file"; key = line.removePrefix(SshScripts.PROJECT_FILE_MARK) } + line == SshScripts.END_MARK -> { close(); section = "end" } + section == null -> blob.append(line).append('\n') + section == "rmt" -> line.trim().takeIf(TmuxNames::isSessionName)?.let(rmt::add) + section == "phone" -> PhoneTerminals.parse(line)?.let(phone::add) + section == "slice" || section == "file" -> body.append(line).append('\n') + } + } + close() + return Output(metaEnd >= 0, ud, blob.toString(), rmt.distinct(), slices, files, relay, phone.distinctBy { it.id }) + } + + /** + * The listing: [base] (the host's own projects, sessions and status, already resolved from its + * index) plus what a desktop that drives this computer over SSH left in [out], as of [now]. + */ + fun assemble(base: ProjectsSnapshot, out: Output, now: Long): ProjectsSnapshot { + // Which socket each session is on. A name on both is the host's own (first wins, the + // desktop's session-memory sweep's rule), and is then not a session of a driven project. + val sockets = LinkedHashMap() + for (s in base.liveSessions) sockets[s] = TmuxNames.SOCKET + for (s in out.rmtSessions) sockets.putIfAbsent(s, TmuxNames.REMOTE_SOCKET) + val rmtOnly = out.rmtSessions.filter { sockets[it] == TmuxNames.REMOTE_SOCKET }.toSet() + + val fresh = freshSlices(out, now) + + val baseIds = base.projects.mapTo(HashSet()) { it.id } + val usedIds = HashSet(baseIds) + // A node is listed once: the host's own index first, then the first driven project naming it. + val claimed = base.projects.flatMapTo(HashSet()) { p -> p.nodes.map { it.id } } + // The host's own folder projects read the same file through their index entry. + val ownFolders = base.projects.mapNotNullTo(HashSet()) { it.cwd?.trimEnd('/')?.ifEmpty { "/" } } + val driven = ArrayList() + + for ((dir, text) in out.projectFiles) { + val folder = dir.trimEnd('/').ifEmpty { "/" } + if (!folder.startsWith("/") || folder in ownFolders) continue + val root = J.obj(J.parse(text)) ?: continue + val fileId = root.s("id") + // The paired desktop's own SSH project (it drives this computer itself): its index entry + // already lists it, and its nodes stay the desktop's to reach (A09). + if (fileId != null && fileId in baseIds) continue + val id = fileId?.takeIf { PROJECT_ID.matches(it) && it !in usedIds } ?: "host:$folder" + if (id in usedIds) continue + // Machine-local fields are never read from the shared file: this computer's folder is the + // one the file was found in, and nothing here says the project is closed. + val content = JsonObject(root - setOf("id", "cwd", "ssh", "closed") + ("id" to JsonPrimitive(id))) + val parsed = ProjectsParser.parseProject(content) ?: continue + val nodes = parsed.nodes.filter { it.id !in claimed }.map { n -> + val c = n.cwd + if (c != null && (c == "." || c.startsWith("./"))) n.copy(cwd = folder.trimEnd('/') + c.removePrefix(".")) else n + } + // Listed when something here is live or reported: a session of one of its nodes runs on + // `nodeterm-rmt`, or its desktop is connected (a fresh slice). A folder that merely has a + // project.json above some session is not a project anyone is driving. + val live = nodes.any { TmuxNames.sessionName(it.id) in rmtOnly } + if (!live && id !in fresh) continue + usedIds += id + nodes.forEach { claimed += it.id } + driven += parsed.copy(cwd = folder, nodes = nodes, drivenRemotely = true) + } + + // A connected desktop's project whose file was not found (its sessions are not running, or + // they start outside its folder, or the file is unreadable): its slice still names its agents. + for ((id, slice) in fresh) { + if (id in usedIds) continue + val nodes = slice.nodes.filterKeys { it !in claimed }.map { (nodeId, st) -> bareNode(nodeId, st.agentId) } + if (nodes.isEmpty()) continue + usedIds += id + nodes.forEach { claimed += it.id } + driven += ProjectInfo( + id = id, name = id, color = null, cwd = null, sshTarget = null, closed = false, + nodes = nodes, board = null, drivenRemotely = true + ) + } + + // Sessions on `nodeterm-rmt` that no project names: still sessions on this computer, so they + // are listed (and can be opened) rather than hidden. + // Compared as session names: a node id with characters tmux names cannot hold maps to `_`. + val claimedSessions = claimed.mapTo(HashSet(), TmuxNames::sessionName) + val orphans = rmtOnly.filter { it !in claimedSessions }.map { it.removePrefix("nt-") }.sorted() + .map { nodeId -> bareNode(nodeId, fresh.values.firstNotNullOfOrNull { it.nodes[nodeId]?.agentId }) } + if (orphans.isNotEmpty()) { + driven += ProjectInfo( + id = OTHER_SESSIONS_ID, name = OTHER_SESSIONS_NAME, color = null, cwd = null, sshTarget = null, + closed = false, nodes = orphans, board = null, drivenRemotely = true + ) + } + + // These plain shells belong to the phone, not either desktop's canvas. Their cwd is shown + // on the node; grouping never edits a shared project.json or invents managed agent status. + val phone = out.phoneTerminals.filter { it.id !in claimed && TmuxNames.sessionName(it.id) !in sockets } + for (entry in phone) sockets[TmuxNames.sessionName(entry.id)] = TmuxNames.PHONE_SOCKET + val phoneProjects = if (phone.isEmpty()) emptyList() else listOf(ProjectInfo( + id = PhoneTerminals.PROJECT_ID, name = PhoneTerminals.PROJECT_NAME, color = null, + cwd = null, sshTarget = null, closed = false, board = null, + nodes = phone.map { bareNode(it.id, null).copy(title = "Terminal", cwd = it.cwd) } + )) + + return base.copy( + projects = base.projects + driven + phoneProjects, + liveSessions = base.liveSessions + rmtOnly + phone.map { TmuxNames.sessionName(it.id) }, + status = mergeStatus(base.status, fresh.values.sortedByDescending { it.updatedAt }), + sockets = sockets + ) + } + + /** + * The slices in [out] that are data as of [now]: named by a valid project id, parseable, and no + * older than [SLICE_STALE_MS]. projectId → slice, in the order they were printed. + */ + fun freshSlices(out: Output, now: Long): Map { + val fresh = LinkedHashMap() + for ((id, text) in out.slices) { + if (!PROJECT_ID.matches(id)) continue + val slice = ProjectsParser.parseStatus(text) ?: continue + if (slice.updatedAt >= now - SLICE_STALE_MS) fresh[id] = slice + } + return fresh + } + + private fun bareNode(id: String, agentId: String?) = NodeInfo( + id = id, kind = NodeKind.TERMINAL, title = "", color = null, agentId = agentId, accountId = null, + cwd = null, parentId = null, iconEmoji = null, agentSessionId = null, text = null + ) + + /** + * The host's own mirror with the fresh slices' nodes and inbox added. The host's own entries win + * (node ids are global, so a clash would be the same node), and its settings, usage and `server` + * block are the host's: a slice carries no usage, and its settings are a fallback for a host that + * runs no nodeterm of its own. + */ + private fun mergeStatus(own: AgentStatusFile?, slices: List): AgentStatusFile? { + if (slices.isEmpty()) return own + val nodes = LinkedHashMap() + own?.nodes?.let(nodes::putAll) + for (s in slices) for ((id, st) in s.nodes) nodes.putIfAbsent(id, st) + val inboxes = listOfNotNull(own?.inbox) + slices.mapNotNull { it.inbox } + val inbox = if (inboxes.isEmpty()) null else { + val seen = HashSet() + val events = inboxes.flatMap { it.events }.filter { seen.add(it.id) }.sortedBy(InboxEvent::ts) + val inboxNodes = LinkedHashMap() + for (i in inboxes) for ((id, n) in i.nodes) inboxNodes.putIfAbsent(id, n) + MirrorInbox(events, inboxNodes) + } + return AgentStatusFile( + updatedAt = maxOf(own?.updatedAt ?: 0, slices.maxOf { it.updatedAt }), + nodes = nodes, + settings = own?.settings ?: slices.firstNotNullOfOrNull { it.settings }, + usage = own?.usage, + inbox = inbox, + server = own?.server + ) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/HostKeyAnchors.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/HostKeyAnchors.kt new file mode 100644 index 0000000000..670412cf45 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/HostKeyAnchors.kt @@ -0,0 +1,41 @@ +package dev.nodeterm.protocol.ssh + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive + +/** + * The computer's SSH host key fingerprints as its pairing handed them over (audit A49-anchor): the + * desktop reads its `/etc/ssh/ssh_host_*_key.pub` (src/main/ssh-host-keys.ts) and puts the + * fingerprints in the SEALED `/pair` answer as `sshHostKeyFingerprints`. The first SSH connect to the + * computer must then present one of them ([SshHostConnection.connect]), instead of pinning whichever + * key answers at the paired address. With none (an older desktop, a plaintext answer, keys the desktop + * could not read, a computer added by its SSH address) the first connect pins the key of the first + * server that accepts the phone's key, as before. + * + * The form is OpenSSH's and sshj's (`SshHostConnection.fingerprint`): `SHA256:` and the unpadded + * standard base64 of the SHA-256 of the key blob, 43 characters. A server presenting a host + * certificate matches by the key it certifies (`SshHostConnection.hostKeyFingerprint`). + */ +object HostKeyAnchors { + /** The most fingerprints kept. sshd serves three or four; the desktop sends at most as many. */ + const val MAX = 16 + + private val FORMAT = Regex("^SHA256:[A-Za-z0-9+/]{43}$") + + /** Whether [s] is a fingerprint in the form [SshHostConnection.fingerprint] gives. */ + fun isFingerprint(s: String): Boolean = FORMAT.matches(s) + + /** + * The fingerprints in a `sshHostKeyFingerprints` value (the sealed answer's, or the phone's own + * record): the well-formed strings, without duplicates, at most [MAX]. Anything that is not an array + * gives none, which leaves the phone where an older desktop leaves it. + */ + fun parse(e: JsonElement?): List { + val array = e as? JsonArray ?: return emptyList() + return array.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } + .filter(::isFingerprint) + .distinct() + .take(MAX) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/LanRefresh.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/LanRefresh.kt new file mode 100644 index 0000000000..6c9e433447 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/LanRefresh.kt @@ -0,0 +1,156 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.serialization.json.JsonElement + +/** + * What the computer says about its own direct-SSH leg beside a relay `projects.list` answer (audit + * A74-refresh): `{ output, lan: { host?, sshHostKeyFingerprints? } }`, written by + * `src/main/remote/host-lan-report.ts`. [host] is its current LAN IPv4, picked as the pairing QR + * picks its `host`; [sshHostKeyFingerprints] are its SSH host keys, in the form the sealed pairing + * answer carries them ([HostKeyAnchors]). An older desktop sends no `lan`, and a Windows one never + * does (it pairs relay-only). + */ +data class LanReport(val host: String?, val sshHostKeyFingerprints: List) { + companion object { + /** + * The `lan` value of a `projects.list` answer. A malformed address is dropped and so are + * malformed fingerprints ([HostKeyAnchors.parse]); null when nothing usable is left, which is + * what an older desktop gives too. + */ + fun parse(e: JsonElement?): LanReport? { + val o = J.obj(e) ?: return null + val host = o.s("host")?.takeIf(::isDialableIPv4) + val keys = HostKeyAnchors.parse(o["sshHostKeyFingerprints"]) + if (host == null && keys.isEmpty()) return null + return LanReport(host, keys) + } + + private val IPV4 = Regex("^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$") + + /** + * A dotted IPv4 the LAN leg may dial: the shape `pickLanIPv4` returns (Node's canonical form, no + * leading zeros), never loopback (the phone would dial itself), `0.0.0.0/8`, link-local + * (`169.254/16`, which the desktop's pick skips) or multicast and above. The computer said it, + * over an authenticated channel; this is only so a desktop bug cannot point the phone at itself. + */ + fun isDialableIPv4(s: String): Boolean { + if (!IPV4.matches(s)) return false + val octets = s.split('.').map(String::toInt) + return when { + octets[0] == 0 || octets[0] == 127 || octets[0] >= 224 -> false + octets[0] == 169 && octets[1] == 254 -> false + else -> true + } + } + } +} + +/** + * Refreshing a paired computer's LAN leg from what the computer itself said over the relay (audit + * A74-refresh). + * + * The LAN leg dials the address the pairing QR carried, a DHCP lease, and checks the SSH host key + * against the pin (or, before the first pin, the keys the sealed pairing answer named). Both were + * facts about the moment of pairing. The relay leg authenticates the computer on its own: the session + * is end-to-end encrypted to the box key this phone pinned at pairing, and served only once approved. + * So a listing that arrived over it may update the record: + * + * - the address, when the computer reports another one; + * - the host keys: they become the anchors (what a first connect, or one after the pin is dropped, + * must present one of); + * - the pin, only when the computer CONFIRMS the key the phone was refused: this connect's SSH leg met + * a key other than the pin at the recorded address ([HostKeyChangedException.actual], handed in as + * `refusedHostKey`), and that key is among the ones the computer reports. Then the pin is dropped, + * so the next SSH connect must present one of the reported keys and pins it once it has + * authenticated. That is how a computer whose sshd keys were regenerated is trusted again without + * pairing anew, and one whose sshd stopped serving the pinned key (the `.pub` still on disk, so + * still reported) gets past the pin, with no trust on first use in between. + * + * A pin is never dropped for being missing from the report alone (review of A74-refresh): the report + * is what nodeterm on the computer could READ of its sshd's keys, which is not always what sshd serves + * (a key with no `.pub` beside it, one named in a config file it cannot open), and a pin that the + * phone has not seen fail is one that works. So without a refusal the pin stays, whatever the report + * says, and no reported keys (an older desktop, keys it could not read) leave the anchors alone too. + * + * Nothing that came over SSH ever does this ([afterListing] refuses any listing that is not a relay + * one): the SSH leg is what these facts check. A computer added by its SSH address has no relay leg, + * and one paired relay-only (a Windows desktop) has no SSH leg, so neither is touched. + * + * Pure, so it is JVM-tested; the app's `HostSession` applies it after each relay listing. + */ +object LanRefresh { + /** + * The record after a refresh. [addressChanged] and [confirmedKey] are what the user may want to + * hear about; anchors that changed alone are not. [confirmedKey] is the key the SSH leg was refused, + * when the computer confirmed it as its own and the next connect accepts it where the record before + * did not (the pin was dropped, or the anchors now name it); null otherwise. + */ + data class Result(val host: PairedHost, val previous: PairedHost, val addressChanged: Boolean, val confirmedKey: String?) + + /** + * What [listed] changes about [host], or null when nothing changes: [kind] is not the relay (a + * direct-SSH listing never refreshes the facts that check it), the listing carries no report, the + * computer has no LAN leg to refresh, or the record already says what the computer said. + * + * [refusedHostKey] is the host key the SSH leg of the connect that opened this relay connection was + * refused at the recorded address ([HostKeyChangedException.actual], which [SshFallback] hands on as + * [SshFallback.Next.TryRelay.refusedHostKey]), or null when that leg was not refused for its key. + */ + fun afterListing(host: PairedHost, kind: TransportKind, listed: ProjectsSnapshot, refusedHostKey: String?): Result? { + if (kind != TransportKind.RELAY) return null + return apply(host, listed.lan ?: return null, refusedHostKey) + } + + /** [afterListing] for a report known to have come over the relay. */ + fun apply(host: PairedHost, report: LanReport, refusedHostKey: String?): Result? { + if (host.manual || !host.sshAvailable) return null + val address = report.host?.takeIf { it != host.host } + val keys = report.sshHostKeyFingerprints + val anchors = if (keys.isEmpty()) host.sshHostKeyAnchors else keys + val pin = host.sshHostKeyFingerprint + val confirmed = refusedHostKey?.takeIf { it in keys } + val dropPin = confirmed != null && pin != null && pin != confirmed + val updated = host.copy( + host = address ?: host.host, + sshHostKeyAnchors = anchors, + sshHostKeyFingerprint = if (dropPin) null else pin + ) + if (updated == host) return null + return Result( + updated, host, + addressChanged = address != null, + confirmedKey = confirmed?.takeIf { !accepts(host, it) && accepts(updated, it) } + ) + } + + /** Whether a connect with [host]'s record accepts a server presenting [fingerprint] (the verifier's rule). */ + private fun accepts(host: PairedHost, fingerprint: String): Boolean { + val pin = host.sshHostKeyFingerprint ?: return host.sshHostKeyAnchors.isEmpty() || fingerprint in host.sshHostKeyAnchors + return pin == fingerprint + } + + /** + * One sentence for the host screen's SSH warning (the one up while the phone is on the relay + * because the SSH leg failed), saying what the refresh changed for the next connect; null when it + * changed nothing the user would act on. It is added to that warning, never instead of it: the + * warning still describes what answered at the old address. + */ + fun note(result: Result): String? { + val parts = ArrayList() + if (result.addressChanged) { + parts += "the computer reported through the relay that its address on your network is now " + + "${result.host.host} (it was ${result.previous.host})" + } + result.confirmedKey?.let { key -> + parts += "the computer confirmed through the relay that the host key its SSH server presented " + + "($key) is one of its own, so the phone will accept it" + } + if (parts.isEmpty()) return null + return "Since then, " + parts.joinToString(", and ") + ". The phone uses this from the next connect on your network." + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/ManagedViewHandshake.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/ManagedViewHandshake.kt new file mode 100644 index 0000000000..16e5a042ec --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/ManagedViewHandshake.kt @@ -0,0 +1,24 @@ +package dev.nodeterm.protocol.ssh + +import java.io.InputStream +import java.io.IOException + +/** Consume exactly the private first line, leaving every terminal byte for SshStream. */ +internal object ManagedViewHandshake { + fun read(input: InputStream, prefix: String = "NT-MANAGED-VIEW "): String { + val line = java.io.ByteArrayOutputStream() + while (line.size() <= 256) { + val next = input.read() + if (next < 0) throw IOException("The host-created terminal refused attachment.") + if (next == 10) { + val text = String(line.toByteArray(), Charsets.UTF_8) + val value = text.removeSuffix("\r").removePrefix(prefix) + if (!text.startsWith(prefix) || !validTty(value)) throw IOException("The terminal returned an invalid attachment receipt.") + return value + } + line.write(next) + } + throw IOException("The host-created terminal returned an oversized attachment receipt.") + } + fun validTty(value: String): Boolean = Regex("^/dev/(?:pts/[0-9]{1,12}|tty[A-Za-z0-9]{1,32})$").matches(value) +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/ManualHost.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/ManualHost.kt new file mode 100644 index 0000000000..0dfc9e6f41 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/ManualHost.kt @@ -0,0 +1,218 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.SshAuthRefusedException +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.pairing.SshIdentity +import java.util.Locale +import java.util.UUID +import javax.net.SocketFactory + +/** + * A computer added by its SSH address instead of a pairing code: "Add SSH server" (audit A27, part b). + * + * The computers a pairing code cannot reach: a headless Server Edition (there is no pairing service + * anywhere in `src/server`), and a dev host the phone reaches only over SSH, where another computer's + * nodeterm runs sessions (`-L nodeterm-rmt`). The browse is the SSH one ([SshScripts], [HostBrowse]), + * which already finds a Server Edition's data dir and what a driving desktop leaves on the computer. + * + * What differs from pairing, and why: + * + * - **The key.** Pairing has the desktop install the phone's key; here nothing on the computer acts + * for the phone, and the phone cannot install its key before it can log in. So the user adds the + * line [authorizedKeysLine] to `~/.ssh/authorized_keys` themselves ([installCommand] does it in one + * line, idempotently). [SshPasswordBootstrap] also offers memory-only password enrollment after + * explicit host fingerprint confirmation. Ordinary connections use the retained phone key. + * - **The host key.** No QR carries anything to check it against, so it is trust on first use, by + * A49's rule: [connectFirst] pins the key of the first server that ACCEPTS this phone's key, never + * of one that merely answered, and the record is made only then. The app shows the pin with + * [FINGERPRINT_CHECK_COMMAND], so the user can compare it on the computer. + * - **No relay, ever.** A Server Edition has no standing phone host, and a plain SSH host's own + * nodeterm (if any) never paired this phone. The record is [PairedHost.manual]; the app fixes its + * route to SSH and never adopts a relay for it, and every relay offer says remote access is not set + * up for this computer. So no "from anywhere" (beyond whatever reaches its SSH, a VPN say) and no + * push: the backend's push fan-out is APNs-only. + * + * Pure but for [connectFirst], which dials; all of it is JVM-tested (`ManualHostTest`). + */ +object ManualHost { + const val DEFAULT_PORT = 22 + + /** The comment on the phone's `authorized_keys` line, which is how the user finds it to revoke it. */ + const val KEY_COMMENT = "nodeterm-android" + + /** + * Prefix of a computer-added-by-address's local id (a paired one's is the desktop's deviceId). The + * record reads it back as [PairedHost.manual] when an older build's save dropped the key. + */ + const val ID_PREFIX = PairedHost.MANUAL_ID_PREFIX + + const val MAX_NAME = 60 + private const val MAX_HOST = 253 + private const val MAX_USER = 64 + + /** How long the first connect may take: longer than a paired computer's LAN dial, since it may go over a VPN. */ + const val FIRST_CONNECT_TIMEOUT_MS = 15_000 + + /** + * Prints the computer's SSH host key fingerprints in the phone's format (`SHA256:…`), one per key + * type; the pinned one is among them. Every OpenSSH since 6.8 prints SHA256 by default. + */ + const val FINGERPRINT_CHECK_COMMAND = "for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf \"\$f\"; done" + + /** What the user types, checked: where to connect, as whom, and what to call it. */ + data class Address(val host: String, val port: Int, val user: String, val name: String) + + sealed interface Check { + data class Ok(val address: Address) : Check + + /** One message per field that is wrong, null for a field that is fine. */ + data class Invalid(val host: String?, val port: String?, val user: String?) : Check + } + + /** + * Check the form. [host] is a host name, an IPv4 address or an IPv6 one (bare or in brackets); + * [port] blank means 22; [name] blank means the host. Nothing here resolves or dials. + */ + fun check(host: String, port: String, user: String, name: String = ""): Check { + val h = host.trim().removeSurrounding("[", "]") + val hostError = hostError(host.trim(), h) + val p = port.trim() + val portNumber = if (p.isEmpty()) DEFAULT_PORT else p.takeIf { it.all(Char::isDigit) && it.length <= 5 }?.toInt() + val portError = if (portNumber == null || portNumber !in 1..65535) "Enter a port from 1 to 65535 (SSH's is 22)." else null + val u = user.trim() + val userError = userError(u) + if (hostError != null || portError != null || userError != null) return Check.Invalid(hostError, portError, userError) + val label = name.filterNot(Char::isISOControl).trim().take(MAX_NAME).trim().ifEmpty { h } + return Check.Ok(Address(h, portNumber!!, u, label)) + } + + private fun hostError(typed: String, h: String): String? { + if (h.isEmpty()) return "Enter the computer's address, like devbox.local or 192.168.1.20." + if ("://" in h) return "Enter the address only, without ssh:// or a path, like devbox.local." + if (h.any(Char::isWhitespace)) return "An address has no spaces." + if ('@' in h) return "Enter only the address here; the user name goes in User." + if (h.length > MAX_HOST) return "That address is too long." + val colons = h.count { it == ':' } + if (colons == 1 && !typed.startsWith("[")) return "Enter the port in Port, not after the address." + if (colons >= 1) { + // IPv6: hex groups, `::`, an embedded IPv4 tail and a `%zone` (fe80::1%wlan0). + val addr = h.substringBefore('%') + val zone = h.substringAfter('%', "") + val ok = addr.isNotEmpty() && addr.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' || it == ':' || it == '.' } && + (zone.isEmpty() || zone.all { it.isLetterOrDigit() || it in "._-" }) + return if (ok) null else "That isn't an IPv6 address." + } + // A host name or an IPv4 address: letters, digits, `-`, `_` and dots, never starting with `-` + // or `.`, and no empty label. + val ok = h.all { it in 'a'..'z' || it in 'A'..'Z' || it.isDigit() || it in "-_." } && + h.first() != '-' && h.first() != '.' && ".." !in h + return if (ok) null else "That isn't a host name or an IP address." + } + + private fun userError(u: String): String? = when { + u.isEmpty() -> "Enter the user name the phone logs in as on the computer." + u.length > MAX_USER -> "That user name is too long." + u.any { it.isWhitespace() || it.isISOControl() } -> "A user name has no spaces." + u.startsWith("-") -> "A user name does not start with -." + ':' in u -> "A user name has no colon." + else -> null + } + + /** + * The computer already in the list under this address — same host (any case), port and user — + * whether paired or added by address. Adding it again would make a second record for one login. + */ + fun existing(hosts: List, address: Address): PairedHost? = hosts.firstOrNull { + it.host.lowercase(Locale.ROOT) == address.host.lowercase(Locale.ROOT) && it.port == address.port && it.user == address.user + } + + fun newId(): String = ID_PREFIX + UUID.randomUUID() + + /** + * The record kept for a computer added by address, made only once [fingerprint] is known — that + * is, once a connect authenticated ([connectFirst]). No relay, no box key: [PairedHost.manual]. + */ + fun record(address: Address, fingerprint: String, id: String = newId(), now: Long = System.currentTimeMillis()): PairedHost { + require(fingerprint.startsWith("SHA256:")) { "a computer is added only with its pinned host key" } + return PairedHost( + id = id, + name = address.name, + host = address.host, + port = address.port, + user = address.user, + sshAvailable = true, + hostKeyB64 = null, + relay = null, + sshHostKeyFingerprint = fingerprint, + pairedAt = now, + manual = true + ) + } + + /** The line the user adds to `~/.ssh/authorized_keys` on the computer. */ + fun authorizedKeysLine(identity: SshIdentity): String = identity.authorizedKeysLine(KEY_COMMENT) + + /** + * One line that adds [keyLine] to the user's `authorized_keys`, run in a terminal on the computer + * as that user. Wrapped in `sh -c '…'` so it is the same in bash, zsh and fish, and safe to run + * twice: the line is added only when it is not there, after a newline when the file's last line + * has none (appending onto it would break both keys), with the modes sshd's StrictModes wants. + * [keyLine] must be an OpenSSH public key line (its characters cannot leave the quotes). + */ + fun installCommand(keyLine: String): String { + require(KEY_LINE.matches(keyLine)) { "not a public key line: $keyLine" } + return "sh -c 'mkdir -p \"\$HOME/.ssh\" && chmod 700 \"\$HOME/.ssh\" && f=\"\$HOME/.ssh/authorized_keys\" && " + + "touch \"\$f\" && chmod 600 \"\$f\" && if ! grep -qxF \"$keyLine\" \"\$f\"; then " + + "if [ -n \"\$(tail -c 1 \"\$f\")\" ]; then echo >> \"\$f\"; fi; echo \"$keyLine\" >> \"\$f\"; fi'" + } + + private val KEY_LINE = Regex("""^ssh-ed25519 [A-Za-z0-9+/]+=* [A-Za-z0-9._-]+$""") + + /** Where the user revokes the phone on a computer added by address (there is no Paired devices list). */ + fun revokeHint(user: String): String = + "To revoke the phone's access on the computer too, remove the line ending in $KEY_COMMENT from " + + "~/.ssh/authorized_keys of $user there." + + /** + * The first connect: dial [address] with this phone's key and, only once the server has accepted + * it, take its host key as the pin (A49's rule; a server that refuses us pins nothing and no record + * is made). Returns the record to keep, with that pin. The connection is closed: the app's own + * connect, verified against the pin, does the browsing. Blocking; throws a [HostException] whose + * message says what to do. + */ + fun connectFirst( + address: Address, + identity: SshIdentity, + id: String = newId(), + now: Long = System.currentTimeMillis(), + connectTimeoutMs: Int = FIRST_CONNECT_TIMEOUT_MS, + socketFactory: SocketFactory? = null + ): PairedHost { + val pin = object : HostKeyPin { + @Volatile var value: String? = null + override fun pinned(): String? = value + override fun pin(fingerprint: String) { + value = fingerprint + } + } + val conn = try { + SshHostConnection.connect(address.host, address.port, address.user, identity, pin, connectTimeoutMs, socketFactory) + } catch (e: SshAuthRefusedException) { + throw HostException(keyNotAccepted(address)) + } catch (e: HostException) { + throw HostException("${e.message} $UNREACHABLE_ADVICE") + } + conn.close() + val fingerprint = pin.value ?: throw HostException("The computer answered without a host key the phone could pin.") + return record(address, fingerprint, id, now) + } + + fun keyNotAccepted(address: Address): String = + "${address.user}@${address.host} answered, but did not accept this phone's key. Add the key line above to " + + "~/.ssh/authorized_keys of ${address.user} on the computer (the command above does it), then connect again." + + const val UNREACHABLE_ADVICE = + "Check the address and the port, that the phone can reach the computer (the same network, or a VPN), and " + + "that its SSH server is on (on a Mac: System Settings → General → Sharing → Remote Login)." +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/PhoneTerminals.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/PhoneTerminals.kt new file mode 100644 index 0000000000..db15719674 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/PhoneTerminals.kt @@ -0,0 +1,51 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.host.HostException +import java.security.MessageDigest + +/** A definitive refusal before this request created or changed a session; a new choice is safe. */ +class SshTerminalCreationRefusedException(message: String) : HostException(message) + +/** Independently owned plain shells, never managed agent nodes on a desktop canvas. */ +object PhoneTerminals { + const val PROJECT_ID = "host:phone-terminals" + const val PROJECT_NAME = "Phone terminals" + const val ID_OPTION = "@nodeterm_phone_id" + const val CREATION_OPTION = "@nodeterm_phone_creation" + const val CWD_OPTION = "@nodeterm_phone_cwd" + const val CREATION_ENV = "NODETERM_PHONE_CREATION" + const val ID_ENV = "NODETERM_PHONE_ID" + const val CWD_ENV = "NODETERM_PHONE_CWD" + const val REQUEST_ENV = "NODETERM_PHONE_REQUEST_CWD" + + private val ID = Regex("^phone-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + private val FINGERPRINT = Regex("^[0-9a-f]{64}$") + + fun validId(id: String): Boolean = ID.matches(id) + fun validCwd(cwd: String): Boolean = cwd.startsWith('/') && cwd.length <= 4096 && + cwd.none { it.code < 32 || it.code in 127..159 } + + fun fingerprint(nodeId: String, cwd: String?): String { + require(validId(nodeId)) { "Invalid phone terminal id." } + require(cwd == null || validCwd(cwd)) { "Choose an absolute folder without control characters." } + val bytes = (nodeId + "\u0000" + (cwd ?: "\u0000HOME")).toByteArray(Charsets.UTF_8) + return MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + } + + data class Entry(val id: String, val cwd: String, val creation: String) + + /** The immutable marker is installed by new-session itself; the options finish its listing. */ + fun parse(line: String): Entry? { + val parts = line.split('\t') + if (parts.size != 6) return null + val (session, id, creation, cwd, marker) = parts + val request = parts[5] + if (!validId(id) || session != TmuxNames.sessionName(id) || !FINGERPRINT.matches(creation) || + marker != creation || !validCwd(cwd)) return null + val requestedCwd = if (request == "HOME") null else request.takeIf(::validCwd) ?: return null + if (requestedCwd != null && requestedCwd != cwd) return null + if (creation != fingerprint(id, requestedCwd)) return null + return Entry(id, cwd, creation) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshActions.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshActions.kt new file mode 100644 index 0000000000..9681a0494f --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshActions.kt @@ -0,0 +1,125 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.HostUnansweredException +import dev.nodeterm.protocol.host.NeedsRelayException +import dev.nodeterm.protocol.host.ManagedSessionChoice +import dev.nodeterm.protocol.host.ManagedSessionReceipt +import dev.nodeterm.protocol.host.ManagedSessionRefusedException +import dev.nodeterm.protocol.host.ManagedSessions +import dev.nodeterm.protocol.host.PreparedManagedSession +import dev.nodeterm.protocol.model.ProjectInfo +import kotlinx.serialization.json.* +import java.util.UUID as JavaUuid + +/** Typed, instance-fenced calls to the selected profile's own WorkspaceStore process. */ +internal class SshActions(private val execute: (String, Long, String?, Boolean, Int?) -> Pair) { + data class Advertisement(val instance: String, val updatedAt: Long, val methods: Set, val remoteProjects: Boolean, val raw: String) + @Volatile private var advertisement: Advertisement? = null + @Volatile private var expiresAtNanos = 0L + val capabilities: HostCapabilities get() { + val a = advertisement?.takeIf { System.nanoTime() < expiresAtNanos } + return HostCapabilities(git = false, boardWrites = a?.methods?.containsAll(BOARD) == true, + nodeActions = a?.methods?.containsAll(NODE) == true, registerNode = false, answerApprovals = true, + managedCreate = a?.methods?.contains(ManagedSessions.METHOD) == true) + } + fun clear() { advertisement = null; expiresAtNanos = 0 } + fun probe(userData: String?): Advertisement? { + clear() + if (userData == null || !SshActionsScripts.validProfile(userData)) return null + val (exit, raw) = execute(SshActionsScripts.probe(userData), 10, null, false, SshActionsScripts.MAX_BYTES + 128) + if (exit != 0) return null + val a = parseAdvertisement(raw) ?: return null + val hostNow = raw.substringBefore('\n').substringAfter('\t').toLongOrNull() ?: return null + advertisement = a + expiresAtNanos = System.nanoTime() + (15_000 - (hostNow - a.updatedAt).coerceAtLeast(0)) * 1_000_000 + return a + } + fun call(userData: String?, method: String, params: JsonObject, targetId: String, owns: (Advertisement) -> Boolean): JsonElement { + require(method in BOARD + NODE) // Creation never enters the fresh-nonce convenience API. + val a = probe(userData) ?: unavailable(targetId) + if (method !in a.methods || !owns(a)) unavailable(targetId) + val nonce = JavaUuid.randomUUID().toString() + val request = buildJsonObject { + put("version", 1); put("instance", a.instance); put("nonce", nonce); put("issuedAt", a.updatedAt) + put("method", method); put("params", params) + }.toString() + require(request.toByteArray(Charsets.UTF_8).size <= SshActionsScripts.MAX_BYTES) { "SSH action is too large." } + val (exit, raw) = execute(SshActionsScripts.submit(userData!!, a, nonce), 20, request, true, SshActionsScripts.MAX_BYTES + 128) + if (exit == 3 && raw.trim() == SshActionsScripts.UNAVAILABLE) { clear(); unavailable(targetId) } + if (exit != 0 || !raw.startsWith("NT-ACTIONS-REPLY\n")) uncertain() + return parseResponse(raw.removePrefix("NT-ACTIONS-REPLY\n").trimEnd('\n'), a.instance, nonce) + } + fun prepareManaged(userData: String?, choice: ManagedSessionChoice, owns: (Advertisement) -> Boolean): PreparedManagedSession { + val a = probe(userData) + if (a == null || ManagedSessions.METHOD !in a.methods || !owns(a)) throw ManagedSessionRefusedException("This profile cannot create a managed session over SSH. Refresh or update nodeterm on the computer.") + return PreparedManagedSession(userData!!, a.instance, JavaUuid.randomUUID().toString(), a.updatedAt, + JavaUuid.randomUUID().toString(), choice, a.raw) + } + /** One dispatch, using exactly the durable request. An uncertain result is never replayed. */ + fun createManaged(request: PreparedManagedSession, owns: (Advertisement) -> Boolean): ManagedSessionReceipt { + val a = probe(request.profile) + if (a == null || a.instance != request.hostInstance || ManagedSessions.METHOD !in a.methods || !owns(a)) { + throw ManagedSessionRefusedException("The selected desktop profile changed before creation was sent. Refresh and choose the project again.") + } + // Keep every original immutable descriptor field, not a new service/nonce after reconnect. + val original = parseAdvertisement("NT-ACTIONS-1\t${request.issuedAt}\n${request.advertisement}") + ?: throw ManagedSessionRefusedException("The saved creation request is invalid.") + if (original.instance != request.hostInstance || original.updatedAt != request.issuedAt) throw ManagedSessionRefusedException("The saved creation request is invalid.") + val (exit, raw) = execute(SshActionsScripts.submit(request.profile, original, request.nonce), 20, + request.wireRequest(), true, SshActionsScripts.MAX_BYTES + 128) + if (exit == 3 && raw.trim() == SshActionsScripts.UNAVAILABLE) throw ManagedSessionRefusedException("The desktop profile changed before creation was published. Refresh and try again.") + if (exit != 0 || !raw.startsWith("NT-ACTIONS-REPLY\n")) uncertain() + val result = try { parseResponse(raw.removePrefix("NT-ACTIONS-REPLY\n").trimEnd('\n'), request.hostInstance, request.nonce) } + catch (e: HostUnansweredException) { throw e } + catch (e: HostException) { throw ManagedSessionRefusedException(e.message ?: "The computer refused creation.") } + return runCatching { ManagedSessions.receipt(result, request) }.getOrElse { uncertain() } + } + private fun unavailable(id: String): Nothing = throw NeedsRelayException(id, + "This desktop profile does not currently serve this action over SSH. Try it through the relay.", + "This desktop profile does not currently serve this action over SSH.", "perform this action") + companion object { + val UUID = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + fun owns(projects: List, projectId: String?, nodeId: String?, ad: Advertisement): Boolean { + val candidates = projects.filter { !it.drivenRemotely && (projectId == null || it.id == projectId) && + (nodeId == null || it.nodes.count { n -> n.id == nodeId } == 1) } + if (nodeId != null && projects.sumOf { p -> p.nodes.count { it.id == nodeId } } != 1) return false + return candidates.size == 1 && (candidates.single().sshTarget == null || ad.remoteProjects) + } + val BOARD = setOf("projects.ensureBoard", "projects.setCardColumn", "projects.editCardLabels") + val NODE = setOf("node.wake", "node.refresh", "node.rename") + val ALL = BOARD + NODE + ManagedSessions.METHOD + fun parseAdvertisement(output: String): Advertisement? = runCatching { + require(output.toByteArray().size <= SshActionsScripts.MAX_BYTES + 128) + val header = output.substringBefore('\n').split('\t') + require(header.size == 2 && header[0] == "NT-ACTIONS-1") + val now = header[1].toLong() + val raw = output.substringAfter('\n').trimEnd('\n') + val o = Json.parseToJsonElement(raw).jsonObject + require(o["version"]?.jsonPrimitive?.let { !it.isString && it.intOrNull == 1 } == true) + val instance = o.getValue("instance").jsonPrimitive.content; require(UUID.matches(instance)) + require(o.getValue("pid").jsonPrimitive.let { !it.isString && it.long in 1..Int.MAX_VALUE }) + val stamp = o.getValue("updatedAt").jsonPrimitive; require(!stamp.isString) + val at = stamp.long + require(at > 0 && now > 0 && at <= now + 30_000 && at >= now - 15_000) + val methods = o.getValue("methods").jsonArray.map { it.jsonPrimitive.also { p -> require(p.isString) }.content }.toSet() + val remote = o["remoteProjects"]?.jsonPrimitive?.let { require(!it.isString); it.boolean } ?: false + Advertisement(instance, at, methods.intersect(ALL), remote, raw) + }.getOrNull() + fun parseResponse(raw: String, instance: String, nonce: String): JsonElement { + val o = runCatching { + require(raw.toByteArray().size <= SshActionsScripts.MAX_BYTES) + Json.parseToJsonElement(raw).jsonObject.also { + require(it["version"]?.jsonPrimitive?.let { p -> !p.isString && p.intOrNull == 1 } == true && it["instance"]?.jsonPrimitive?.content == instance && it["nonce"]?.jsonPrimitive?.content == nonce) + require(it["ok"]?.jsonPrimitive?.let { p -> !p.isString && p.booleanOrNull != null } == true) + require(it["uncertain"] == null || it["uncertain"]?.jsonPrimitive?.let { p -> !p.isString && p.booleanOrNull != null } == true) + } + }.getOrElse { uncertain() } + if (o["uncertain"]?.jsonPrimitive?.booleanOrNull == true) uncertain() + if (o.getValue("ok").jsonPrimitive.boolean) return o["result"] ?: uncertain() + throw HostException((o["error"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: "The desktop refused this action.") + } + fun uncertain(): Nothing = throw HostUnansweredException("No confirmed answer came back for this action. Check the computer before repeating it.") + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshActionsScripts.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshActionsScripts.kt new file mode 100644 index 0000000000..87da12df2e --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshActionsScripts.kt @@ -0,0 +1,103 @@ +package dev.nodeterm.protocol.ssh + +/** Files are private to the authenticated OS user; no hook bearer or second workspace writer. */ +internal object SshActionsScripts { + const val MAX_BYTES = 128 * 1024 + const val UNAVAILABLE = "NT-ACTIONS-UNAVAILABLE" + private fun q(value: String) = SshScripts.q(value) + private val guards = """ + umask 077 + uid=$(id -u) || exit 3 + meta() { stat -c '%u %a %h %s %d %i' -- "${'$'}1" 2>/dev/null || stat -f '%u %Lp %l %z %d %i' "${'$'}1" 2>/dev/null; } + dir() { + [ -d "${'$'}1" ] && [ ! -L "${'$'}1" ] || return 1 + mask=${'$'}2; m=$(meta "${'$'}1") || return 1; set -- ${'$'}m + [ "${'$'}1" = "${'$'}uid" ] || return 1 + mode=${'$'}2; [ ${'$'}((0${'$'}mode & ${'$'}mask)) -eq 0 ] + } + read_private() { + [ -f "${'$'}1" ] && [ ! -L "${'$'}1" ] || return 1 + before=$(meta "${'$'}1") || return 1; set -- "${'$'}1" ${'$'}before + [ "${'$'}2" = "${'$'}uid" ] && [ ${'$'}((0${'$'}3 & 077)) -eq 0 ] && [ "${'$'}4" = 1 ] && [ "${'$'}5" -le 131072 ] || return 1 + value=$(head -c 131073 "${'$'}1") || return 1 + after=$(meta "${'$'}1") || return 1 + [ "${'$'}before" = "${'$'}after" ] || return 1 + } + clock() { seconds=$(date +%s) || return 1; now=${'$'}((seconds * 1000)); } + unavailable() { echo NT-ACTIONS-UNAVAILABLE; exit 3; } + """.trimIndent() + + fun validProfile(path: String): Boolean = path.startsWith('/') && path.length <= 4096 && path.none { it.code < 32 || it.code in 127..159 } + + fun probe(userData: String): String { + require(validProfile(userData)) + return guards + "\n" + """ + ud=${q(userData)} + root="${'$'}ud/ssh-actions" + dir "${'$'}ud" 022 && dir "${'$'}root" 077 && read_private "${'$'}root/advertisement.json" || unavailable + clock || unavailable + printf 'NT-ACTIONS-1\t%s\n%s\n' "${'$'}now" "${'$'}value" + """.trimIndent() + } + + /** Final attachment reads the same selected profile and a fresh unchanged service identity. */ + fun adoptionGuard(userData: String, ad: SshActions.Advertisement): String { + require(validProfile(userData)) + val identity = ad.raw.replace(Regex("\"updatedAt\":\\d+"), "\"updatedAt\":0") + return guards + "\n" + """ + ud=${q(userData)}; root="${'$'}ud/ssh-actions"; directory="${'$'}root/${ad.instance}" + expected=${q(identity)} + nt_managed_ad() { + dir "${'$'}ud" 022 && dir "${'$'}root" 077 && dir "${'$'}directory" 077 && read_private "${'$'}root/advertisement.json" || return 1 + identity=$(printf '%s' "${'$'}value" | sed 's/"updatedAt":[0-9][0-9]*/"updatedAt":0/') + [ "${'$'}identity" = "${'$'}expected" ] || return 1 + stamp=$(printf '%s' "${'$'}value" | sed -n 's/.*"updatedAt":\([0-9][0-9]*\).*/\1/p') + case "${'$'}stamp" in ''|*[!0-9]*) return 1;; esac + clock || return 1 + [ "${'$'}stamp" -le ${'$'}((now + 30000)) ] && [ "${'$'}stamp" -ge ${'$'}((now - 15000)) ] + } + nt_managed_ad || unavailable + """.trimIndent() + } + + /** Heartbeats may replace the descriptor; only immutable identity/capability fields are pinned. */ + fun submit(userData: String, ad: SshActions.Advertisement, nonce: String): String { + require(validProfile(userData) && SshActions.UUID.matches(nonce)) + val identity = ad.raw.replace(Regex("\"updatedAt\":\\d+"), "\"updatedAt\":0") + return guards + "\n" + """ + ud=${q(userData)}; root="${'$'}ud/ssh-actions"; instance=${q(ad.instance)} + expected=${q(identity)}; directory="${'$'}root/${'$'}instance" + check_ad() { + dir "${'$'}ud" 022 && dir "${'$'}root" 077 && dir "${'$'}directory" 077 && read_private "${'$'}root/advertisement.json" || return 1 + identity=$(printf '%s' "${'$'}value" | sed 's/"updatedAt":[0-9][0-9]*/"updatedAt":0/') + [ "${'$'}identity" = "${'$'}expected" ] || return 1 + stamp=$(printf '%s' "${'$'}value" | sed -n 's/.*"updatedAt":\([0-9][0-9]*\).*/\1/p') + case "${'$'}stamp" in ''|*[!0-9]*) return 1;; esac + clock || return 1 + [ "${'$'}stamp" -le ${'$'}((now + 30000)) ] && [ "${'$'}stamp" -ge ${'$'}((now - 15000)) ] + } + check_ad || unavailable + tmp="${'$'}directory/.${nonce}.upload"; request="${'$'}directory/${nonce}.request"; response="${'$'}directory/${nonce}.response" + [ ! -e "${'$'}request" ] && [ ! -L "${'$'}request" ] && [ ! -e "${'$'}response" ] && [ ! -L "${'$'}response" ] || { echo NT-ACTIONS-UNCERTAIN; exit 4; } + (set -C; : > "${'$'}tmp") 2>/dev/null || { echo NT-ACTIONS-UNCERTAIN; exit 4; } + trap 'rm -f "${'$'}tmp"' EXIT HUP INT TERM + # The client limits UTF-8 input before dispatch; cap stdin again on the host. + head -c 131073 > "${'$'}tmp" || exit 4 + size=$(wc -c < "${'$'}tmp"); [ "${'$'}size" -le 131072 ] || exit 4 + check_ad || unavailable + # Rename publishes one complete regular file; -n refuses an existing nonce without overwriting it. + mv -n "${'$'}tmp" "${'$'}request" || { echo NT-ACTIONS-UNCERTAIN; exit 4; } + [ ! -e "${'$'}tmp" ] || { echo NT-ACTIONS-UNCERTAIN; exit 4; } + i=0 + while [ "${'$'}i" -lt 30 ]; do + if [ -e "${'$'}response" ] || [ -L "${'$'}response" ]; then + read_private "${'$'}response" || { echo NT-ACTIONS-UNCERTAIN; exit 4; } + printf 'NT-ACTIONS-REPLY\n%s\n' "${'$'}value"; exit 0 + fi + check_ad || { echo NT-ACTIONS-UNCERTAIN; exit 4; } + i=${'$'}((i + 1)); sleep 0.5 + done + echo NT-ACTIONS-UNCERTAIN; exit 4 + """.trimIndent() + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshComposedInput.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshComposedInput.kt new file mode 100644 index 0000000000..761eae24e8 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshComposedInput.kt @@ -0,0 +1,115 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.host.ComposedInput +import dev.nodeterm.protocol.model.TmuxNames +import java.util.UUID + +/** A receipt captured while THIS tmux viewer attaches, never resolved from a later selected node. */ +internal data class SshInputViewer( + val tty: String, val viewerPid: Long, val viewerCreated: String, val serverPid: Long, + val session: String, val sessionCreated: String, val sessionId: String, val paneId: String, val panePid: Long, + val viewerBirth: String, val serverBirth: String, val paneBirth: String, +) { + val tuple: String get() = listOf(tty, viewerPid, viewerCreated, serverPid, session, sessionCreated, sessionId, paneId, panePid).joinToString("|") +} + +/** Native tmux owns paste framing and the final client/pane gate in one command queue. */ +internal object SshComposedInput { + private const val FORMAT = "#{client_tty}|#{client_pid}|#{client_created}|#{pid}|#{session_name}|#{session_created}|#{session_id}|#{pane_id}|#{pane_pid}" + private val number = Regex("^[1-9][0-9]*$") + private fun birthValid(value: String) = value.length <= 128 && + (Regex("^linux:[0-9a-f-]{36}:[0-9]+$").matches(value) || Regex("^darwin:[A-Za-z]{3} [A-Za-z]{3} [0-9]{1,2} [0-9]{2}:[0-9]{2}:[0-9]{2} [0-9]{4}$").matches(value)) + + fun parse(raw: String, tty: String, nodeId: String): SshInputViewer? { + val fields = raw.trim().split('|') + if (fields.size != 13 || fields[0] != "NT-COMPOSED-VIEW" || fields[1] != tty || !ManagedViewHandshake.validTty(tty) || + fields[5] != TmuxNames.sessionName(nodeId) || !Regex("^\\$[0-9]+$").matches(fields[7]) || + !Regex("^%[0-9]+$").matches(fields[8]) || listOf(2, 3, 4, 6, 9).any { !number.matches(fields[it]) } || + listOf(10, 11, 12).any { !birthValid(fields[it]) }) return null + val viewerPid = fields[2].toLongOrNull() ?: return null + val serverPid = fields[4].toLongOrNull() ?: return null + val panePid = fields[9].toLongOrNull() ?: return null + return SshInputViewer(tty, viewerPid, fields[3], serverPid, fields[5], fields[6], fields[7], fields[8], panePid, fields[10], fields[11], fields[12]) + } + + private val birthFunction = """ + nt_input_birth() { + case "${'$'}1" in ''|*[!0-9]*) return 1 ;; esac + if [ -r "/proc/${'$'}1/stat" ]; then + nt_boot=${'$'}(cat /proc/sys/kernel/random/boot_id) || return 1 + nt_stat=${'$'}(cat "/proc/${'$'}1/stat") || return 1 + nt_rest=${'$'}{nt_stat##*) }; set -- ${'$'}nt_rest + [ "${'$'}#" -ge 20 ] || return 1; shift 19 + case "${'$'}1" in ''|*[!0-9]*) return 1 ;; esac + printf 'linux:%s:%s' "${'$'}nt_boot" "${'$'}1" + else + nt_started=${'$'}(LC_ALL=C ps -o lstart= -p "${'$'}1" 2>/dev/null | awk '{ ${'$'}1=${'$'}1; print }') || return 1 + [ -n "${'$'}nt_started" ] || return 1 + printf 'darwin:%s' "${'$'}nt_started" + fi + } + """.trimIndent() + + private fun clients(socket: String, session: String, tty: String) = + "\"\$NT_TMUX\" -L ${SshScripts.q(socket)} list-clients -t ${SshScripts.q("=$session")} -F ${SshScripts.q(FORMAT)} | awk -F '|' -v tty=${SshScripts.q(tty)} '\$1 == tty { print }'" + + fun capture(nodeId: String, socket: String, tty: String, creation: String?): String { + require(ManagedViewHandshake.validTty(tty)) + val query = clients(socket, TmuxNames.sessionName(nodeId), tty) + return """ + ${SshScripts.composedPrelude(nodeId, socket, creation)} + $birthFunction + nt_wait=0; nt_row='' + while [ "${'$'}nt_wait" -lt 30 ]; do + nt_row=${'$'}($query) || exit 3 + [ -n "${'$'}nt_row" ] && break + nt_wait=${'$'}((nt_wait + 1)); sleep 0.1 + done + [ -n "${'$'}nt_row" ] || exit 3 + nt_viewer=${'$'}(printf '%s' "${'$'}nt_row" | cut -d '|' -f 2) + nt_server=${'$'}(printf '%s' "${'$'}nt_row" | cut -d '|' -f 4) + nt_pane=${'$'}(printf '%s' "${'$'}nt_row" | cut -d '|' -f 9) + nt_vbirth=${'$'}(nt_input_birth "${'$'}nt_viewer") || exit 3 + nt_sbirth=${'$'}(nt_input_birth "${'$'}nt_server") || exit 3 + nt_pbirth=${'$'}(nt_input_birth "${'$'}nt_pane") || exit 3 + [ "${'$'}($query)" = "${'$'}nt_row" ] || exit 3 + printf 'NT-COMPOSED-VIEW|%s|%s|%s|%s\n' "${'$'}nt_row" "${'$'}nt_vbirth" "${'$'}nt_sbirth" "${'$'}nt_pbirth" + """.trimIndent() + } + + fun send(nodeId: String, socket: String, creation: String?, viewer: SshInputViewer, input: ComposedInput): String { + require(input.valid() && parse("NT-COMPOSED-VIEW|${viewer.tuple}|${viewer.viewerBirth}|${viewer.serverBirth}|${viewer.paneBirth}", viewer.tty, nodeId) == viewer) + require(input !is ComposedInput.Paste || input.text.isEmpty() || !input.enter) { "Paste and Enter require separate guarded dispatches" } + val pane = viewer.paneId + val buffer = "nt-paste-" + UUID.randomUUID().toString().replace("-", "") + val parts = listOf("#{==:#{pid},${viewer.serverPid}}", "#{==:#{session_id},${viewer.sessionId}}", + "#{==:#{session_created},${viewer.sessionCreated}}", "#{==:#{pane_id},$pane}", "#{==:#{pane_pid},${viewer.panePid}}") + val client = listOf("#{==:#{client_tty},${viewer.tty}}", "#{==:#{client_pid},${viewer.viewerPid}}", + "#{==:#{client_created},${viewer.viewerCreated}}", "#{==:#{client_session},${viewer.session}}", "#{==:#{pid},${viewer.serverPid}}", + "#{==:#{session_id},${viewer.sessionId}}", "#{==:#{session_created},${viewer.sessionCreated}}", + "#{==:#{pane_id},$pane}", "#{==:#{pane_pid},${viewer.panePid}}") + .reduce { a, b -> "#{&&:$a,$b}" } + val condition = (parts + "#{L:#{?$client,1,}}").reduce { a, b -> "#{&&:$a,$b}" } + val cancel = "if-shell -F -t $pane '#{pane_in_mode}' 'send-keys -t $pane -X cancel'" + val write = when (input) { + is ComposedInput.Control -> "send-keys -t $pane -H ${input.text[0].code.toString(16).padStart(2, '0')}" + is ComposedInput.Paste -> (if (input.text.isNotEmpty()) "paste-buffer -d -p -r -b $buffer -t $pane ; " else "") + + (if (input.enter) "send-keys -t $pane Enter ; " else "") + "display-message -p nt-composed-delivered" + } + val yes = "$cancel ; $write" + if (input is ComposedInput.Control) " ; display-message -p nt-composed-delivered" else "" + val no = "display-message -p nt-composed-refused" + val query = clients(socket, viewer.session, viewer.tty) + val hasBuffer = input is ComposedInput.Paste && input.text.isNotEmpty() + return """ + ${SshScripts.composedPrelude(nodeId, socket, creation)} + $birthFunction + [ "${'$'}($query)" = ${SshScripts.q(viewer.tuple)} ] || { printf 'nt-composed-refused\n'; exit 0; } + [ "${'$'}(nt_input_birth ${viewer.viewerPid})" = ${SshScripts.q(viewer.viewerBirth)} ] && + [ "${'$'}(nt_input_birth ${viewer.serverPid})" = ${SshScripts.q(viewer.serverBirth)} ] && + [ "${'$'}(nt_input_birth ${viewer.panePid})" = ${SshScripts.q(viewer.paneBirth)} ] || { printf 'nt-composed-refused\n'; exit 0; } + ${if (hasBuffer) "trap '\"\$NT_TMUX\" -L ${SshScripts.q(socket)} delete-buffer -b $buffer >/dev/null 2>&1 || :' EXIT HUP INT TERM" else ""} + "${'$'}NT_TMUX" -L ${SshScripts.q(socket)} ${if (hasBuffer) "load-buffer -b $buffer - \\;" else ""} \ + if-shell -F -t ${SshScripts.q(pane)} ${SshScripts.q(condition)} ${SshScripts.q(yes)} ${SshScripts.q(no)} + """.trimIndent() + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshFallback.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshFallback.kt new file mode 100644 index 0000000000..5aa8c304bf --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshFallback.kt @@ -0,0 +1,123 @@ +package dev.nodeterm.protocol.ssh + +/** + * What a connect does after the direct-SSH leg failed (audit A49/A74). + * + * The LAN leg dials the address the computer had when it was paired: usually a DHCP lease on a + * private range. After the lease moved, or with the phone on another network that uses the same + * range, a DIFFERENT machine running sshd can answer there, and its host key does not match the pin. + * That machine is never used over SSH. But it is no reason to skip the relay: the relay leg + * authenticates the computer on its own (the relay host key from pairing, then the SAS approval), + * so going on to it loses no trust. Before this, a changed key stopped the Auto route outright, and + * the only way out it named was re-pairing. + * + * Only the "Only on my network" route keeps the hard stop, because there is nothing else it may + * try. Whether the relay leg may DIAL is not decided here: the caller still asks its + * [dev.nodeterm.protocol.host.RelayApprovalGate], so a background check never makes a first relay + * handshake because of this. + * + * Pure, so it is JVM-tested; the app's `HostSession` applies it. + */ +object SshFallback { + sealed interface Next { + /** + * Go on to the relay leg. [error] joins what is shown if that fails too; [warning] is shown + * even when it succeeds (a changed host key must not disappear behind a working relay). + * [refusedHostKey] is the key the SSH server presented when it was refused for it + * ([HostKeyChangedException.actual]): what the relay's report may confirm as the computer's own + * ([LanRefresh], review of A74-refresh). Null for any other failure. + */ + data class TryRelay(val error: String, val warning: String? = null, val refusedHostKey: String? = null) : Next + + /** Try nothing else: fail with [message]. */ + data class Stop(val message: String) : Next + } + + /** Where the per-computer route setting lives, as Settings names it. */ + const val RELAY_ONLY_SETTING = + "choose “Only through the relay” for this computer in Settings → How to reach each computer." + + /** + * A reinstalled computer's new key is trusted again once the computer itself confirms it through the + * relay ([LanRefresh], audit A74-refresh: a current nodeterm reports its keys on every relay listing, + * and the relay connect that follows this refusal hands the refused key over as + * [Next.TryRelay.refusedHostKey]), or by pairing it again. Neither is the only way out, and neither + * trusts whatever answered at the address. + */ + const val REPAIR_NOTE = "If the computer was reinstalled, the phone trusts its new key once the computer confirms it " + + "through the relay (nodeterm on the computer must be up to date), or when you pair it again." + + /** The relay is not an option yet: say how to get one. */ + const val NO_RELAY_ADVICE = + "This phone has no relay connection to it yet: turn on remote access in nodeterm on the computer " + + "(Settings → Phone) and pair again, which also trusts the computer's current key." + + /** + * For a key none of the reported keys name ([HostKeyNotPairedException], audit A49-anchor), in + * place of [REPAIR_NOTE] (review of A49-anchor). Those keys are what nodeterm on the computer read + * of its own SSH server, at pairing or since in a relay report ([LanRefresh]), and both read them + * the same way (`src/main/ssh-host-keys.ts`). So pairing again, or a report, changes which key the + * phone accepts only when the computer's keys changed since. When its SSH server uses a key that + * reader cannot see (one named in a config file it cannot open, or with no `.pub` beside it), every + * pairing and every report names the same keys again: promising otherwise sends the user round a + * loop that ends where it started, and the relay is the way that works. + */ + const val NOT_REPORTED_NOTE = + "Pairing again, or the computer reporting its keys through the relay, changes this only if its SSH " + + "host keys changed since it reported them: nodeterm on the computer reads them the same way each " + + "time, so if its SSH server uses a key nodeterm cannot read there, the phone keeps refusing it on " + + "your network and reaches it only through the relay." + + /** + * [NO_RELAY_ADVICE] for a [HostKeyNotPairedException]: pair again to GET a relay leg, without the + * promise that it also trusts the computer's key (see [NOT_REPORTED_NOTE]). + */ + const val NOT_REPORTED_NO_RELAY_ADVICE = + "This phone has no relay connection to it yet: turn on remote access in nodeterm on the computer " + + "(Settings → Phone) and pair again, and the phone reaches it through the relay when your network " + + "refuses it. Pairing again changes which SSH key the phone accepts only if the computer's keys " + + "changed since it was paired: nodeterm on the computer reads them the same way each time, so if " + + "its SSH server uses a key nodeterm cannot read there, the phone keeps refusing it on your network." + + /** + * A computer added by its SSH address (audit A27) has no relay leg to fall back to, and no pairing + * to repeat: forgetting it and adding it again is what trusts a new key. + */ + const val ADDED_OVER_SSH_KEY_ADVICE = + "This computer was added by its SSH address, so the phone has no other way to reach it. If it was " + + "reinstalled, forget it on this phone and add it again, which trusts its new key." + + /** + * @param relayAllowed the route lets this connect use the relay (anything but "Only on my network"). + * @param relayConfigured the phone holds a relay leg for this computer (endpoint, relay host key and + * device token), i.e. a relay dial is possible at all. + * @param addedOverSsh the computer was added by its SSH address ([dev.nodeterm.protocol.ssh.ManualHost]): + * SSH is its only route, and the failure is said without "your network" or a relay to turn on. + */ + fun afterFailure(error: Throwable, relayAllowed: Boolean, relayConfigured: Boolean, addedOverSsh: Boolean = false): Next { + if (addedOverSsh) { + val message = error.message ?: error.javaClass.simpleName + return Next.Stop(if (error is HostKeyChangedException) "$message $ADDED_OVER_SSH_KEY_ADVICE" else message) + } + if (error is HostKeyChangedException) { + val fact = error.message ?: "This computer's SSH host key changed." + // A changed pin and a key the computer never reported take the same route, but not the same + // promise: re-reading the computer's keys trusts a regenerated key, never one it cannot read. + val notReported = error is HostKeyNotPairedException + val noRelay = if (notReported) NOT_REPORTED_NO_RELAY_ADVICE else NO_RELAY_ADVICE + val note = if (notReported) NOT_REPORTED_NOTE else REPAIR_NOTE + return when { + !relayConfigured -> Next.Stop("$fact $noRelay") + !relayAllowed -> Next.Stop("$fact To reach it through the relay instead, $RELAY_ONLY_SETTING $note") + else -> Next.TryRelay( + error = "On your network: $fact To reach it through the relay only, $RELAY_ONLY_SETTING $note", + warning = "$fact The phone connected through the relay instead, which checks the computer's " + + "identity separately. To stop trying your network for it, $RELAY_ONLY_SETTING $note", + refusedHostKey = error.actual + ) + } + } + val line = "On your network: ${error.message ?: error.javaClass.simpleName}" + return if (relayAllowed) Next.TryRelay(line) else Next.Stop(line) + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshGit.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshGit.kt new file mode 100644 index 0000000000..488792c233 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshGit.kt @@ -0,0 +1,173 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.HostUnansweredException +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.s +import kotlinx.serialization.json.* + +/** SSH producer for the existing GitReplies consumer. No shell command or revision comes from RPC. */ +internal class SshGit( + private val cwd: String, + private val roots: List, + private val run: (script: String, write: Boolean) -> Pair +) { + private fun exec(args: List, write: Boolean = false, repositoryRoot: Boolean = true): Pair { + val (code, out) = run(SshGitScripts.command(cwd, roots, args, repositoryRoot), write) + if (code == null) { + if (write) throw HostUnansweredException("The SSH computer did not confirm the Git operation. It may have finished.") + throw HostException("The SSH computer did not confirm the Git reply.") + } + if (code == SshGitScripts.REFUSED || code == SshGitScripts.MISSING) throw HostException(out.trim()) + return code to out + } + + private fun read(args: List): String { + val (code, out) = exec(args) + if (code != 0) throw HostException(out.trim().ifEmpty { "Git failed (exit $code)." }) + return out + } + + fun request(verb: GitVerb, args: Map): JsonElement = when (verb) { + GitVerb.STATUS -> status() + GitVerb.DIFF -> { + val o = JsonObject(args) + val path = SshGitScripts.path(o.s("path") ?: throw HostException("Choose a file to compare.")) + val untracked = o.b("untracked") == true + val argv = buildList { + add("diff"); add("--no-ext-diff"); add("--no-textconv"); add("--color=never") + if (untracked) add("--no-index") else if (o.b("staged") == true) add("--cached") + add("--"); if (untracked) add("/dev/null"); add(path) + } + val (code, out) = exec(argv) + if (code != 0 && !(untracked && code == 1)) throw HostException(out.trim().ifEmpty { "Git diff failed (exit $code)." }) + JsonPrimitive(out) + } + GitVerb.STAGE, GitVerb.UNSTAGE -> { + val values = args["paths"] as? JsonArray ?: throw HostException("Choose files to stage or unstage.") + if (values.size > 4096) throw HostException("Too many files in one Git request.") + val paths = values.map { SshGitScripts.path((it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content + ?: throw HostException("Invalid Git filename.")) } + if (paths.sumOf { it.length } > 128_000) throw HostException("Too many filenames in one Git request.") + if (paths.isEmpty()) result(true, "") else write( + (if (verb == GitVerb.STAGE) listOf("add", "--") else listOf("restore", "--staged", "--")) + paths + ) + } + GitVerb.COMMIT -> { + val message = (args["message"] as? JsonPrimitive)?.takeIf { it.isString }?.content + ?: throw HostException("Invalid commit message.") + if ('\u0000' in message || message.length > 128_000) throw HostException("Invalid commit message.") + if (message.isBlank()) result(false, "Commit message is empty.") else write(listOf("commit", "-m", message)) + } + GitVerb.PUSH -> { + val (branchCode, branchOut) = exec(listOf("symbolic-ref", "--quiet", "--short", "HEAD")) + val branch = branchOut.trim() + val (code, out) = exec(listOf("push"), write = true) + if (code == 0) result(true, "Pushed.") else if (code == 128 && branchCode == 0 && branch.isNotEmpty() && !branch.startsWith('-') && + out.lineSequence().firstOrNull() == "fatal: The current branch $branch has no upstream branch.") { + // Native fatal 128 before dispatch, for the branch observed before this push. + // Remote/hook rejection prose and every unanswered write MUST remain a single attempt. + write(listOf("push", "-u", "origin", branch), "Pushed (set upstream).") + } else result(false, out.trim()) + } + GitVerb.PULL -> write(listOf("pull"), "Pulled.") + GitVerb.HISTORY -> history() + } + + private fun write(args: List, success: String = ""): JsonObject { + val (code, out) = exec(args, write = true) + return result(code == 0, if (code == 0) success else out.trim().ifEmpty { "Git failed (exit $code)." }) + } + + private fun status(): JsonObject { + // An ordinary folder is a useful empty status; missing Git/jail failures are not. + val (inside, answer) = exec(listOf("rev-parse", "--is-inside-work-tree"), repositoryRoot = false) + if (inside != 0 || answer.trim() != "true") return resultStatus(false) + val raw = read(listOf("status", "--porcelain=v2", "-z", "--branch", "-uall")) + val stagedStats = numstat(read(listOf("diff", "--no-ext-diff", "--no-textconv", "--cached", "--numstat", "-z"))) + val changedStats = numstat(read(listOf("diff", "--no-ext-diff", "--no-textconv", "--numstat", "-z"))) + val remote = read(listOf("remote")).isNotBlank() + val root = read(listOf("rev-parse", "--show-toplevel")).trimEnd('\n', '\r') + return parseStatus(raw, root.substringAfterLast('/'), remote, stagedStats, changedStats) + } + + private fun history(): JsonObject { + val (code, raw) = exec(listOf("log", "--no-show-signature", "-51", "-z", "--format=%H%x00%h%x00%s%x00%an%x00%at%x00%D")) + if (code != 0) { + // A new/unborn branch has no commits; other errors remain errors. + val (headCode, _) = exec(listOf("rev-parse", "--verify", "HEAD")) + if (headCode != 0) return buildJsonObject { put("items", JsonArray(emptyList())); put("hasMore", false) } + throw HostException(raw.trim()) + } + val entries = raw.removeSuffix("\u0000").split('\u0000') + if (entries.size % 6 != 0) throw HostException("The SSH computer returned an unreadable Git history.") + val all = entries.chunked(6).map { f -> buildJsonObject { + put("id", f[0]); put("displayId", f[1]); put("subject", f[2]); put("author", f[3]) + f[4].toLongOrNull()?.let { put("timestamp", it * 1000L) } + put("references", JsonArray(f[5].split(", ").filter { it.isNotEmpty() }.map { ref -> buildJsonObject { put("name", ref.removePrefix("HEAD -> ")) } })) + } } + val statusRaw = read(listOf("status", "--porcelain=v2", "-z", "--branch", "-uno")) + val meta = parseStatus(statusRaw, "", false) + val branch = meta.s("branch") ?: "HEAD" + val upstream = branchHeaders(statusRaw)["branch.upstream"] + return buildJsonObject { + put("items", JsonArray(all.take(50))); put("hasMore", all.size > 50) + put("currentRef", buildJsonObject { put("name", if (branch == "HEAD") all.firstOrNull()?.s("displayId") ?: "HEAD" else branch) }) + upstream?.let { put("remoteRef", buildJsonObject { put("name", it) }) } + put("hasIncomingChanges", (meta["behind"] as? JsonPrimitive)?.intOrNull?.let { it > 0 } == true) + put("hasOutgoingChanges", (meta["ahead"] as? JsonPrimitive)?.intOrNull?.let { it > 0 } == true) + } + } + + companion object { + private fun result(ok: Boolean, message: String) = buildJsonObject { put("ok", ok); put("message", message) } + private fun resultStatus(hasRepo: Boolean) = buildJsonObject { + put("hasRepo", hasRepo); put("staged", JsonArray(emptyList())); put("changes", JsonArray(emptyList())) + } + private fun branchHeaders(raw: String): Map = raw.split('\u0000').filter { it.startsWith("# ") }.associate { + val line = it.removePrefix("# "); line.substringBefore(' ') to line.substringAfter(' ', "") + } + internal fun numstat(raw: String): Map> { + val fields = raw.split('\u0000'); val stats = LinkedHashMap>(); var i = 0 + while (i < fields.size) { + val parts = fields[i++].split('\t', limit = 3) + if (parts.size != 3) continue + val path = if (parts[2].isEmpty()) { + if (i + 1 >= fields.size) throw HostException("Unreadable Git rename statistics.") + i++; fields[i++] // the destination, not the former name + } else parts[2] + stats[path] = (parts[0].toIntOrNull() ?: 0) to (parts[1].toIntOrNull() ?: 0) + } + return stats + } + internal fun parseStatus(raw: String, repoName: String, hasRemote: Boolean, + stagedStats: Map> = emptyMap(), changedStats: Map> = emptyMap()): JsonObject { + val headers = branchHeaders(raw); val staged = ArrayList(); val changed = ArrayList() + val fields = raw.split('\u0000'); var i = 0 + fun entry(path: String, status: Char, stats: Map>) = buildJsonObject { + put("path", path); put("status", status.toString()); put("added", stats[path]?.first ?: 0); put("deleted", stats[path]?.second ?: 0) + } + while (i < fields.size) { + val f = fields[i++]; if (f.isEmpty() || f.startsWith("# ") || f.startsWith("! ")) continue + if (f.startsWith("? ")) { changed += entry(f.substring(2), 'U', emptyMap()); continue } + val type = f.first(); val count = when (type) { '1' -> 9; '2' -> 10; 'u' -> 11; else -> throw HostException("Unreadable Git status.") } + val parts = f.split(' ', limit = count) + if (parts.size != count || parts[1].length != 2) throw HostException("Unreadable Git status.") + val xy = parts[1]; val path = parts.last() + if (type == '2') { if (i >= fields.size) throw HostException("Unreadable Git rename."); i++ } + if (xy[0] != '.') staged += entry(path, xy[0], stagedStats) + if (xy[1] != '.') changed += entry(path, xy[1], changedStats) + } + val ab = headers["branch.ab"]?.split(' ') ?: emptyList() + return buildJsonObject { + put("hasRepo", true); put("repoName", repoName) + put("branch", headers["branch.head"]?.takeUnless { it == "(detached)" } ?: "HEAD") + put("ahead", ab.firstOrNull()?.removePrefix("+")?.toIntOrNull() ?: 0) + put("behind", ab.getOrNull(1)?.removePrefix("-")?.toIntOrNull() ?: 0) + put("hasRemote", hasRemote); put("hasUpstream", headers.containsKey("branch.upstream")) + put("staged", JsonArray(staged)); put("changes", JsonArray(changed)) + } + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshGitScripts.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshGitScripts.kt new file mode 100644 index 0000000000..0a6395f8eb --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshGitScripts.kt @@ -0,0 +1,70 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.host.HostException + +/** Typed argv only. The SSH account's Git config, credentials, hooks and signing stay its own. */ +object SshGitScripts { + const val REFUSED = 125 + const val MISSING = 126 + const val MAX_OUTPUT = 20 * 1024 * 1024 + + fun path(path: String): String { + if (path.isEmpty() || path.length > 16_384 || '\u0000' in path || path.startsWith('/') || + path.split('/').any { it == ".." }) throw HostException("Choose a file inside this repository.") + return path + } + + /** Recheck physical paths on the host for every operation, including symlinks and renames. */ + fun command(cwd: String, roots: List, args: List, repositoryRoot: Boolean = true): String { + if (!cwd.startsWith('/') || '\u0000' in cwd || cwd.length > 16_384 || roots.isEmpty()) + throw HostException("Source control needs a listed project folder on this computer.") + val safeRoots = roots.filter { it.startsWith('/') && '\u0000' !in it && it.length <= 16_384 }.distinct() + if (safeRoots.isEmpty() || args.any { '\u0000' in it }) throw HostException("Invalid Git request.") + val jail = safeRoots.joinToString("\n") { root -> + """ + nt_root=${SshScripts.q(root)} + nt_root=${'$'}(CDPATH= cd -P "${'$'}nt_root" 2>/dev/null && pwd -P && printf .) || nt_root= + if [ -n "${'$'}nt_root" ]; then nt_root=${'$'}{nt_root%.}; nt_root=${'$'}{nt_root%?}; fi + if [ -n "${'$'}nt_root" ]; then + case "${'$'}nt_cwd" in "${'$'}nt_root"|"${'$'}nt_root"/*) nt_allowed=1 ;; esac + [ "${'$'}nt_root" != / ] || nt_allowed=1 + fi + """.trimIndent() + } + val rootCheck = if (repositoryRoot) """ + nt_repo=${'$'}(command git rev-parse --show-toplevel 2>/dev/null && printf .) || { + printf '%s\n' 'nodeterm: This folder is not a Git working tree.'; exit ${REFUSED} + } + nt_repo=${'$'}{nt_repo%.}; nt_repo=${'$'}{nt_repo%?} + CDPATH= cd -P "${'$'}nt_repo" 2>/dev/null || exit ${REFUSED} + nt_cwd=${'$'}(pwd -P && printf .) + nt_cwd=${'$'}{nt_cwd%.}; nt_cwd=${'$'}{nt_cwd%?} + nt_allowed=0 + $jail + [ "${'$'}nt_allowed" = 1 ] || { + printf '%s\n' 'nodeterm: The repository root is outside the listed project folders.'; exit ${REFUSED} + } + """.trimIndent() else "" + return """ + PATH="${'$'}PATH:/opt/homebrew/bin:/usr/local/bin:${'$'}HOME/.local/bin" + export PATH GIT_TERMINAL_PROMPT=0 + unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CEILING_DIRECTORIES GIT_DISCOVERY_ACROSS_FILESYSTEM + nt_cwd=${SshScripts.q(cwd)} + CDPATH= cd -P "${'$'}nt_cwd" 2>/dev/null || { + printf '%s\n' 'nodeterm: That project folder cannot be opened.'; exit ${REFUSED} + } + nt_cwd=${'$'}(pwd -P && printf .) + nt_cwd=${'$'}{nt_cwd%.}; nt_cwd=${'$'}{nt_cwd%?} + nt_allowed=0 + $jail + [ "${'$'}nt_allowed" = 1 ] || { + printf '%s\n' 'nodeterm: That folder is outside the listed project folders.'; exit ${REFUSED} + } + command -v git >/dev/null 2>&1 || { + printf '%s\n' 'nodeterm: Git is not installed or not on the SSH PATH.'; exit ${MISSING} + } + $rootCheck + command git --no-pager --literal-pathspecs ${args.joinToString(" ", transform = SshScripts::q)} 2>&1 + """.trimIndent() + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt new file mode 100644 index 0000000000..f34baffffa --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt @@ -0,0 +1,1155 @@ +package dev.nodeterm.protocol.ssh + +import com.hierynomus.sshj.userauth.certificate.Certificate +import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.ManagedSessionAdoption +import dev.nodeterm.protocol.host.ManagedSessionChoice +import dev.nodeterm.protocol.host.ManagedSessionReceipt +import dev.nodeterm.protocol.host.ManagedSessionRefusedException +import dev.nodeterm.protocol.host.ManagedSessions +import dev.nodeterm.protocol.host.PreparedManagedSession +import dev.nodeterm.protocol.host.HostUnansweredException +import dev.nodeterm.protocol.host.LabelEditResult +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.host.NeedsRelayException +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.NewSessionHint +import dev.nodeterm.protocol.host.SshAuthRefusedException +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.ComposedInput +import dev.nodeterm.protocol.host.ComposedInputResult +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.HookReplies +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.J.b +import dev.nodeterm.protocol.model.J.o +import dev.nodeterm.protocol.model.J.objects +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.model.KanbanLabel +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.pairing.SshIdentity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.* +import net.schmizz.keepalive.KeepAliveProvider +import net.schmizz.keepalive.KeepAliveRunner +import net.schmizz.sshj.DefaultConfig +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.connection.channel.direct.Session +import net.schmizz.sshj.transport.verification.HostKeyVerifier +import net.schmizz.sshj.userauth.UserAuthException +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import java.io.OutputStream +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.PublicKey +import java.util.Base64 +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit +import javax.net.SocketFactory + +/** + * The pin for the computer's SSH host key (`SHA256:`, OpenSSH's format). + * + * [pin] is called only once the server has ACCEPTED this phone's key (audit A49), never during the + * key exchange: a machine that merely answered at the paired address — another computer that now + * has that DHCP lease, or the same private range on another network — refuses our key, and it must + * not become the pin. + * + * Before the first pin, [anchors] decides which keys may become it (audit A49-anchor): the + * fingerprints the computer named in its sealed pairing answer ([HostKeyAnchors]). A server whose + * key is none of them is refused during the key exchange, before this phone's key is offered. With no + * anchors (an older or Windows desktop, keys it could not read, a computer added by its SSH address) + * the first server that accepts our key becomes the pin: trust on first use. + * + * What is pinned is [SshHostConnection.hostKeyFingerprint]: for a host certificate, the key it + * certifies, never the certificate itself (review of A74-refresh). + */ +interface HostKeyPin { + /** The pinned fingerprint, or null until a connect has authenticated with this phone's key. */ + fun pinned(): String? + + /** + * Called with the authenticated server's key when nothing is pinned, and once more to re-spell a + * pin an older build took from a host certificate's own fingerprint as the key that certificate + * certifies (the same key; review of A74-refresh). Both only after the server accepted our key. + */ + fun pin(fingerprint: String) + + /** The fingerprints the first connect must present one of, from pairing; empty = trust on first use. */ + fun anchors(): List = emptyList() +} + +/** + * The server at the paired address presented a key other than the pinned one. The message states + * the fact and its likely causes; what the user can do about it depends on the route, and + * [SshFallback] adds it. + */ +open class HostKeyChangedException protected constructor( + val expected: String, + val actual: String, + message: String +) : Exception(message) { + constructor(expected: String, actual: String) : this( + expected, + actual, + "This computer's SSH host key changed (expected $expected, got $actual), so the phone did not connect " + + "to it over your network. Another machine may now have its network address (a different Wi-Fi, or " + + "a reassigned address), or the computer was reinstalled; if neither, someone may be intercepting " + + "the connection." + ) +} + +/** + * The first SSH connect to a paired computer met a host key that is none of the keys the computer + * named in its sealed pairing answer (audit A49-anchor), or reported since through the relay (audit + * A74-refresh, [LanRefresh]). Nothing was pinned, and this phone's key was never offered. A + * [HostKeyChangedException], so [SshFallback] routes it as one: never used over SSH, and in Auto the + * relay leg, which checks the computer on its own, is still tried. Its advice differs + * ([SshFallback.NOT_REPORTED_NOTE]): pairing again re-reads the same keys, so it is no way out when the + * computer's SSH server uses a key nodeterm on it cannot read. + */ +class HostKeyNotPairedException(val paired: List, actual: String) : HostKeyChangedException( + paired.joinToString(" or "), + actual, + "This computer's SSH server presented a host key ($actual) that is " + + (if (paired.size == 1) "not the key" else "none of the ${paired.size} keys") + + " the computer reported to this phone (when it was paired, or since through the relay), so the phone did " + + "not connect to it over your network. " + + "Another machine may have its network address (a different Wi-Fi, or a reassigned address), or the " + + "computer's SSH server uses a key nodeterm on it could not read; if neither, someone may be " + + "intercepting the connection." +) + +/** + * The browse found nothing of nodeterm's on the computer: not an empty computer, but the wrong place + * to look (audit A02). Its [message] ends by offering the relay; [said] picks the ending the phone can + * stand behind for the relay leg it has (review of A27b) — a computer added by its SSH address has no + * relay and never will, and one without a usable relay leg cannot be "connected through the relay". + */ +class NothingFoundException : HostException(SshHostConnection.NO_USER_DATA) { + fun said(leg: LegRouting.RelayLeg): String = when (leg) { + // The phone holds a relay leg: open, or one a route change opens. + LegRouting.RelayLeg.AVAILABLE, LegRouting.RelayLeg.ROUTE_SSH_ONLY -> SshHostConnection.NO_USER_DATA + LegRouting.RelayLeg.ADDED_OVER_SSH -> SshHostConnection.NO_USER_DATA_ADDED_OVER_SSH + LegRouting.RelayLeg.NOT_SET_UP, LegRouting.RelayLeg.NOT_PICKED_UP, LegRouting.RelayLeg.REMOTE_ACCESS_OFF -> + SshHostConnection.NO_USER_DATA_NO_RELAY + } +} + +/** + * [HostConnection] over direct SSH (the LAN leg a pairing installs a key for). Everything is POSIX + * sh + tmux and typed Git on the computer. The selected-profile service owns Board writes and + * Desktop renderer nudges, plus managed New on its enabled local POSIX tmux backend. Existing + * missing canvas sessions stay attach-only; creation uses host-owned launch settings. + */ +class SshHostConnection private constructor(private val client: SSHClient, private val profilePath: String?) : HostConnection { + override val kind = TransportKind.SSH + private val actions = SshActions { script, timeout, stdin, write, limit -> + run(script, timeout, stdin, uncertainWrite = write, outputLimit = limit) + } + override val capabilities: HostCapabilities get() = actions.capabilities.copy(git = true) + private data class ActionListing(val userData: String?, val projects: List) + @Volatile private var actionListing = ActionListing(null, emptyList()) + + @Volatile private var onClosed: ((String?) -> Unit)? = null + @Volatile private var userData: String? = null + + /** + * Whether the computer advertised its relay (`~/.nodeterm/relay.json`) at the last listing: the + * desktop writes that file while its phone host is registered at the relay and removes it when + * remote access is turned off (src/main/remote/relay-advertise.ts). A relay token the phone holds + * outlives that toggle, so this is what tells "remote access is off on the computer" apart from a + * working relay leg (audit A26). Null before the first listing, or when the listing did not say. + */ + @Volatile var relayAdvertised: Boolean? = null + private set + + @Volatile private var closedFired = false + + private fun fireClosed(reason: String?) { + actions.clear() + if (closedFired) return + closedFired = true + onClosed?.invoke(reason) + } + + /** True while the SSH transport is up. */ + val isConnected: Boolean get() = client.isConnected && client.isAuthenticated && client.socket?.isClosed != true + + /** + * Run a script through `/bin/sh -c` and return stdout (bounded wait). A dead transport is + * reported through `onClosed` (so the owner reconnects) and surfaces as a [HostException] — + * never as a raw sshj exception a caller would have to know about. + */ + internal fun run(script: String, timeoutSec: Long = 20, stdin: String? = null, + uncertainWrite: Boolean = false, outputLimit: Int? = null): Pair { + // A REAL deadline (audit A31): the read below waits on the channel with no timeout of its + // own, so a peer that vanished mid-command (laptop asleep, IP or VPN change) blocked it for + // as long as TCP took to give up — ~15 minutes. When the deadline passes, the connection is + // treated as dead: the transport is torn down (which also wakes the blocked read) and the + // drop is reported, so Auto falls back to the relay instead of sitting on "On your network". + val timedOut = java.util.concurrent.atomic.AtomicBoolean(false) + var dispatched = false + val deadline = WATCHDOG.schedule({ + timedOut.set(true) + disconnectQuietly() + }, timeoutSec, TimeUnit.SECONDS) + try { + client.startSession().use { session -> + // A lost exec acknowledgement is uncertain too: the peer may already be running it. + dispatched = true + val cmd = session.exec("/bin/sh -c " + SshScripts.q(script)) + if (stdin != null) { + cmd.outputStream.use { it.write(stdin.toByteArray(Charsets.UTF_8)) } + } + val out = if (outputLimit == null) cmd.inputStream.readBytes() else { + val bytes = java.io.ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (true) { + val n = cmd.inputStream.read(buffer) + if (n < 0) break + if (bytes.size() + n > outputLimit) throw java.io.IOException("SSH output exceeded the $outputLimit byte limit") + bytes.write(buffer, 0, n) + } + bytes.toByteArray() + } + cmd.join(5, TimeUnit.SECONDS) + if (timedOut.get()) throw java.util.concurrent.TimeoutException("no answer within ${timeoutSec}s") + if (uncertainWrite && cmd.exitStatus == null) throw java.io.IOException("the computer did not confirm an exit status") + return cmd.exitStatus to String(out, Charsets.UTF_8) + } + } catch (e: HostException) { + throw e + } catch (e: Exception) { + // A command that cannot even be run (the channel would not open, the transport timed out, + // the deadline passed) says the CONNECTION is unusable, whatever `isConnected` claims: + // drop it and report the drop, so the owner reconnects — or falls back to the relay. + disconnectQuietly() + fireClosed(e.message ?: e.javaClass.simpleName) + val message = "The SSH connection failed: ${e.message ?: e.javaClass.simpleName}" + if (uncertainWrite && dispatched) throw HostUnansweredException(message) + throw HostException(message) + } finally { + deadline.cancel(false) + } + } + + override suspend fun listProjects(): ProjectsSnapshot = withContext(Dispatchers.IO) { browseNow() } + + /** [listProjects], blocking: one browse, and what it says about the computer's nodes remembered. */ + private fun browseNow(): ProjectsSnapshot { + val now = System.currentTimeMillis() + val (code, raw) = run(SshScripts.browse(profilePath)) + if (code == SshScripts.SELECTED_PROFILE_MISSING_EXIT) { + remember(ProjectsSnapshot.EMPTY) + userData = null; actionListing = ActionListing(null, emptyList()); actions.clear() + relayAdvertised = null + throw HostException("The selected SSH profile folder is missing or has no nodeterm profile files. Check the folder in Settings on this phone and the data directory on the computer.") + } + val out = HostBrowse.split(raw) + val ud = out.userData + // Nothing found is not "a computer with no sessions": it means we are looking in the wrong + // place (audit A02 shipped exactly that as an empty list), or at a nodeterm whose data dir we + // cannot know (a Server Edition with another --data-dir). Say so instead. + relayAdvertised = out.relayAdvertised + if (out.nothingFound(now)) { + // Still an answer about the nodes: none here is anyone's (no index, nothing driven). + remember(ProjectsSnapshot.EMPTY) + userData = null; actionListing = ActionListing(null, emptyList()); actions.clear() + throw NothingFoundException() + } + userData = ud + val base = ProjectsParser.parseBlob(out.blob) + val wsText = out.blob.substringBefore(ProjectsParser.PROJECTS_MARK) + val root = J.obj(J.parse(wsText)) + val own = if (root != null && J.long(root["version"]) == 3L) { + base.copy(projects = resolveIndexV3(root, ud)) + } else { + base + } + // What a desktop that drives this computer over SSH left here, next to the host's own (A27). + actionListing = ActionListing(ud, own.projects.filterNot { it.drivenRemotely }) + actions.probe(ud) + val snapshot = HostBrowse.assemble(own, out, now) + remember(snapshot, out.phoneTerminals.associate { it.id to it.creation }) + return snapshot + } + + /** + * The on-disk workspace.json is a v3 INDEX: folder refs carry no nodes (their canvas is in + * `/.nodeterm/project.json`), SSH refs carry an offline `cache`, local-data refs a + * `userData/inline-projects/.json` (with `project` as its cache). One extra exec reads every + * referenced file; an unreadable one keeps the entry with whatever the index itself had. + */ + private fun resolveIndexV3(root: JsonObject, ud: String?): List { + val entries = root.objects("entries") + val paths = ArrayList() + val fileIndex = HashMap() + for ((i, e) in entries.withIndex()) { + val cwd = e.s("cwd") + val path = when { + cwd != null && e.o("ssh") == null -> "$cwd/.nodeterm/project.json" + e.b("dataFile") == true && ud != null && e.s("id") != null -> "$ud/inline-projects/${e.s("id")}.json" + else -> null + } + if (path != null) { + fileIndex[i] = paths.size + paths.add(path) + } + } + val files = HashMap() + if (paths.isNotEmpty()) { + val (_, out) = run(SshScripts.catFiles(paths), timeoutSec = 30) + val parts = out.split("\n" + SshScripts.FILE_MARK) + for (part in parts.drop(1)) { + val nl = part.indexOf('\n') + val idx = (if (nl >= 0) part.substring(0, nl) else part).trim().toIntOrNull() ?: continue + files[idx] = if (nl >= 0) part.substring(nl + 1) else "" + } + } + return entries.withIndex().mapNotNull { (i, e) -> + val id = e.s("id") ?: return@mapNotNull null + val fileText = fileIndex[i]?.let { files[it] } + val content: JsonObject? = J.obj(fileText?.let(J::parse)) ?: e.o("cache") ?: e.o("project") + val merged = buildMap { + content?.let { putAll(it) } + // The ENTRY is the identity and the machine-local half (#510): id, name, color, closed. + for (k in listOf("id", "name", "color", "closed", "cwd", "ssh")) e[k]?.let { put(k, it) } + } + val project = ProjectsParser.parseProject(JsonObject(merged)) ?: return@mapNotNull null + val cwd = e.s("cwd") + // Folder-ref node cwds are stored portable ("./…") — resolve them against the root. + if (cwd != null) project.copy(nodes = project.nodes.map { n -> + val c = n.cwd + if (c != null && (c == "." || c.startsWith("./"))) n.copy(cwd = cwd.trimEnd('/') + c.removePrefix(".")) else n + }) else project + } + } + + /** + * Explicitly start one independent plain shell, with no managed agent hook/account state. + * A lost/cancelled reply may leave the shell running: retry the SAME id and folder to reconcile + * it. Neither cancellation nor a viewer detach kills a successfully created terminal. + */ + suspend fun createTerminal(nodeId: String, cwd: String? = null): Unit = withContext(Dispatchers.IO) { + val script = SshScripts.createTerminal(nodeId, cwd) + val (code, out) = run(script) + if (code != 0 || out.trim() != "created") { + val message = out.trim().takeIf { it.startsWith("nodeterm:") } + ?: "Couldn't create the terminal over SSH (exit ${code ?: "unknown"}). Retry the same terminal." + if (code == 2 && out.trim() in setOf( + "nodeterm: Choose an absolute folder.", + "nodeterm: That folder no longer exists or cannot be opened.", + "nodeterm: This terminal id is already in use.", + "nodeterm: This terminal id belongs to another session." + )) throw SshTerminalCreationRefusedException(message) + throw HostException(message) + } + } + + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream { + // The blocking open finishes even when the caller is cancelled meanwhile, and withContext + // then drops its result: a tmux client attached for the life of the connection, which + // nobody holds and nothing detaches (audit A40; the same shape as A20's dial). + var opened: SshStream? = null + try { + return withContext(Dispatchers.IO) { + ensureListed() + refuseRemoteNode(nodeId) + val phoneCreation = if (PhoneTerminals.validId(nodeId)) ownedPhoneCreation(nodeId) else null + // Where the session is NOW, on either socket (A27): one exec that is also the + // existence check, so a session listed on one socket is never attached on the other. + val socket = run(SshScripts.whichSocket(nodeId, phoneCreation)).second.trim().takeIf { it in TmuxNames.SOCKETS } + ?: throw notRunning(nodeId) + val session = client.startSession() + try { + session.allocatePTY("xterm-256color", cols, rows, 0, 0, emptyMap()) + val cmd = session.exec("/bin/sh -c " + SshScripts.q(SshScripts.attach(nodeId, socket, phoneCreation, inputHandshake = true))) + val deadline = WATCHDOG.schedule({ runCatching { session.close() } }, 10, TimeUnit.SECONDS) + val viewer = try { + val tty = ManagedViewHandshake.read(cmd.inputStream, "NT-INPUT-VIEW ") + captureInputViewer(nodeId, socket, tty, phoneCreation) + } finally { deadline.cancel(false) } + SshStream(session, cmd, fresh = false, sink = sink, nodeId = nodeId, socket = socket, inputViewer = viewer, phoneCreation = phoneCreation).also { + opened = it + it.start() + } + } catch (e: Exception) { + runCatching { session.close() } + throw HostException("Couldn't open the terminal over SSH: ${e.message}") + } + } + } catch (e: CancellationException) { + opened?.let { s -> withContext(NonCancellable) { s.detach() } } + throw e + } + } + + private fun ownsManagedChoice(listing: ActionListing, choice: ManagedSessionChoice): Boolean { + val candidates = listing.projects.filter { it.id == choice.projectId } + return candidates.size == 1 && candidates.single().let { + !it.closed && !it.drivenRemotely && it.sshTarget == null && it.cwd != null + } + } + override suspend fun prepareManagedSession(choice: ManagedSessionChoice): PreparedManagedSession = withContext(Dispatchers.IO) { + ensureListed() + val listing = actionListing + actions.prepareManaged(listing.userData, choice) { actionListing === listing && ownsManagedChoice(listing, choice) } + } + override suspend fun createManagedSession(request: PreparedManagedSession): ManagedSessionReceipt = withContext(Dispatchers.IO) { + ensureListed() + val listing = actionListing + if (listing.userData != request.profile) throw ManagedSessionRefusedException("The selected desktop profile changed before creation was sent.") + actions.createManaged(request) { actionListing === listing && ownsManagedChoice(listing, request.choice) } + } + override suspend fun attachManagedSession(adoption: ManagedSessionAdoption, cols: Int, rows: Int, sink: TerminalSink): TerminalStream { + var opened: SshStream? = null + try { + return withContext(Dispatchers.IO) { + browseNow() // Re-resolve the committed node's unique profile ownership before adoption. + val listing = actionListing + val r = adoption.receipt + if (listing.userData != adoption.request.profile || !ownsManagedChoice(listing, adoption.request.choice) || + listing.projects.sumOf { p -> p.nodes.count { it.id == r.nodeId } } != 1 || + listing.projects.singleOrNull { it.id == r.projectId }?.nodes?.count { it.id == r.nodeId } != 1) { + throw HostException("The created terminal's desktop profile or project is no longer available. Check it on the computer.") + } + val ad = actions.probe(listing.userData) + if (ad == null || ad.instance != r.hostInstance || ManagedSessions.METHOD !in ad.methods) { + throw HostException("The desktop restarted or creation is no longer available. Check the computer before discarding this saved receipt; the session will not be created again.") + } + val (code, proof) = run(SshScripts.attachManaged(adoption, ad, attach = false, profilePath = profilePath), outputLimit = 256) + if (code != 0 || proof.trim() != "NT-MANAGED-VERIFIED") throw HostException("The host-created terminal changed or ended. Refresh the sessions list; no replacement was started.") + val channel = client.startSession() + try { + channel.allocatePTY("xterm-256color", cols, rows, 0, 0, emptyMap()) + val command = channel.exec("/bin/sh -c " + SshScripts.q(SshScripts.attachManaged(adoption, ad, profilePath = profilePath))) + val deadline = WATCHDOG.schedule({ runCatching { channel.close() } }, 10, TimeUnit.SECONDS) + var viewer: SshInputViewer? = null + try { + val tty = ManagedViewHandshake.read(command.inputStream) + val (attached, confirmed) = run(SshScripts.attachManaged(adoption, ad, attach = false, clientTty = tty, profilePath = profilePath), timeoutSec = 8, outputLimit = 256) + if (attached != 0 || confirmed.trim() != "NT-MANAGED-VERIFIED") throw HostException("The created terminal could not confirm this SSH viewer. Check it on the computer; no replacement was started.") + viewer = captureInputViewer(r.nodeId, r.socket, tty, null) + } finally { deadline.cancel(false) } + // The host already created and launched this generation; this attach is warm. + SshStream(channel, command, fresh = false, sink = sink, nodeId = r.nodeId, socket = r.socket, inputViewer = viewer, phoneCreation = null).also { + opened = it; it.start() + } + } catch (e: Exception) { + runCatching { channel.close() } + throw HostException("Couldn't view the host-created terminal over SSH: ${e.message}") + } + } + } catch (e: CancellationException) { + opened?.let { s -> withContext(NonCancellable) { s.detach() } } + throw e + } + } + + private fun captureInputViewer(nodeId: String, socket: String, tty: String, creation: String?): SshInputViewer? { + val (code, raw) = run(SshComposedInput.capture(nodeId, socket, tty, creation), timeoutSec = 5, outputLimit = 2048) + // Viewing remains supported if an older host cannot attest composed input. Send refuses. + return if (code == 0) SshComposedInput.parse(raw, tty, nodeId) else null + } + + /** Node ids of the desktop's SSH projects → `user@host`, from the latest listing. */ + @Volatile private var remoteNodes: Map = emptyMap() + + /** Remember which nodes belong to the desktop's SSH projects (their tmux is on another host). */ + fun rememberRemoteNodes(snapshot: ProjectsSnapshot) { + remoteNodes = snapshot.projects.filter { it.sshTarget != null } + .flatMap { p -> p.nodes.map { it.id to p.sshTarget!! } }.toMap() + } + + /** Session name → tmux socket, from the latest listing ([ProjectsSnapshot.sockets]). */ + @Volatile private var sockets: Map = emptyMap() + + /** Nodes of projects a desktop elsewhere drives over SSH ([ProjectInfo.drivenRemotely]). */ + @Volatile private var drivenNodes: Set = emptySet() + + /** + * Nodes of the computer's OWN index (its folder and data projects, closed ones included; not its + * SSH projects, whose nodes are [remoteNodes]): the only nodes the relay of this computer starts + * as the computer's own sessions ([notRunning]). + */ + @Volatile private var ownNodes: Set = emptySet() + /** Fingerprints validated from the atomic creation tuple, pinned before each phone action. */ + @Volatile private var phoneOwners: Map = emptyMap() + /** Only folders on this SSH computer. A third-machine SSH cache never authorizes local Git. */ + @Volatile private var gitRoots: List = emptyList() + + /** Whether a browse has answered on THIS connection ([ensureListed]). */ + @Volatile private var listed = false + private val listLock = Any() + + private fun remember(snapshot: ProjectsSnapshot, phone: Map = emptyMap()) { + rememberRemoteNodes(snapshot) + sockets = snapshot.sockets + drivenNodes = snapshot.projects.filter { it.drivenRemotely }.flatMapTo(HashSet()) { p -> p.nodes.map { it.id } } + ownNodes = snapshot.projects.filter { !it.drivenRemotely && it.sshTarget == null } + .flatMapTo(HashSet()) { p -> p.nodes.map { it.id } } + phoneOwners = phone + gitRoots = snapshot.projects.filter { it.sshTarget == null }.mapNotNull { it.cwd }.distinct() + listed = true + } + + private fun ownedPhoneCreation(nodeId: String): String { + phoneOwners[nodeId]?.let { return it } + // A create may have followed this connection's last browse; discover it once before acting. + try { browseNow() } catch (_: NothingFoundException) { /* no phone terminal is an answer */ } + return phoneOwners[nodeId] ?: throw notRunning(nodeId) + } + + /** + * Settle what this connection knows of the computer's nodes before a node-scoped decision (the + * review of A27a). Which nodes are the desktop's SSH projects' ([remoteNodes]), which a desktop + * elsewhere drives ([drivenNodes]) and which are the computer's own ([ownNodes]) comes from a + * listing, and a connection that has not listed yet knew none of them: a redial after a drop, or + * a terminal restored after the process died, attaches at once. A node's refusal then took the + * "nothing known" branch, which offered this computer's relay for a driven session that was not + * running, and let a node of an SSH project through to this computer's tmux. So the first + * node-scoped call on a connection lists, once; "nothing found" is an answer too. A failed + * transport is not, and fails the call. + */ + private fun ensureListed() { + if (listed) return + synchronized(listLock) { + if (listed) return + try { + browseNow() + } catch (e: HostException) { + if (!listed) throw e + } + } + } + + /** + * The socket [nodeId]'s session is on (audit A27): the one the latest listing saw it on, else the + * one it is on now ([SshScripts.whichSocket], one exec), else the socket a session of its kind + * would be on — where the command then fails as "no such session", which is the truth. + */ + private fun socketFor(nodeId: String): String { + if (PhoneTerminals.validId(nodeId)) return TmuxNames.PHONE_SOCKET + sockets[TmuxNames.sessionName(nodeId)]?.let { return it } + run(SshScripts.whichSocket(nodeId)).second.trim().takeIf { it in TmuxNames.SOCKETS }?.let { return it } + return if (nodeId in drivenNodes) TmuxNames.REMOTE_SOCKET else TmuxNames.SOCKET + } + + /** + * A node of one of the desktop's SSH projects lives on ANOTHER host: its tmux session, its + * pending approvals and its read-acks are all there, not on this computer. Over direct SSH we can + * only reach this computer, so every node-scoped action refuses (audit A09) — attaching would + * create a phantom local session and offer to resume the agent on the wrong machine. + */ + private fun refuseRemoteNode(nodeId: String) { + if (PhoneTerminals.validId(nodeId)) return // never route a reserved phone id to a creating relay + val where = remoteNodes[nodeId] ?: return + throw NeedsRelayException( + nodeId, + "This session runs on $where, which the phone reaches through your computer: it opens through the relay, not over your network.", + fact = "This session runs on $where, not on this computer, and the phone reaches it only through the relay." + ) + } + + /** + * Why [nodeId]'s session cannot be opened when it is not running. The relay is offered only for a + * node of the computer's OWN index ([ownNodes]): there the relay's `pty.attach` is nodeterm on + * this computer starting its own session (audit A08). For any other node it would make a bare + * `nt-` on this computer's `node-terminal` socket, a session of no project with no hook + * environment, which later attaches then find first (host-service.ts `handleAttach` creates what + * it does not find). That covers a driven project's node, and a node no listing names at all: a + * driven project no longer listed (its desktop quit and its sessions ended), a deleted node. + */ + private fun notRunning(nodeId: String): Exception = when { + PhoneTerminals.validId(nodeId) -> HostException("This phone terminal has ended or is no longer owned by the phone. Start a new terminal over SSH.") + // Its desktop is not the one behind this connection's relay: there is nothing to offer. + nodeId in drivenNodes -> HostException(DRIVEN_NOT_RUNNING) + nodeId in ownNodes -> NeedsRelayException( + nodeId, + "This session isn't running on the computer right now. Starting it over your network would leave it " + + "without status reporting, so it opens through the relay instead (or open it in nodeterm on the computer).", + fact = "This session isn't running on the computer right now, and the phone does not start it over " + + "SSH: it would run without status reporting.", + action = "start it" + ) + else -> HostException(NOT_RUNNING_UNLISTED) + } + + /** + * The terminal stream over one exec'd pty channel. + * + * EVERY socket write goes through ONE serial executor owned by the stream ([io]), never the + * caller's thread (audit A01/A04). [TerminalStream] is a non-suspend contract, and its callers + * include the Android MAIN thread (resize on a keyboard/rotation/A−/A+, the ^C key chips, Resume, + * Fit) as well as the WebView's JavaBridge thread (typed input). A socket write on the main thread + * throws `NetworkOnMainThreadException` — and it throws AFTER sshj's `Encoder.encode` has advanced + * the packet sequence number and the cipher stream, so the transport is corrupt from then on and + * the very next packet drops the connection. One executor also keeps the two producers IN ORDER + * (a ^C chip can neither overtake nor split typed bytes), which fixing each call site would not. + * + * Failures are never swallowed: a [RuntimeException] out of the write path means the transport's + * state is unknown, so the whole connection is torn down ([breakTransport]) and reported through + * `onClosed`; an [java.io.IOException] with the transport still up is only this CHANNEL closing + * (tmux exited), which the reader thread reports as the stream's exit. + */ + private inner class SshStream( + private val session: Session, + private val cmd: Session.Command, + override val fresh: Boolean, + private val sink: TerminalSink, + private val nodeId: String, + private val socket: String, + private val inputViewer: SshInputViewer?, + private val phoneCreation: String? + ) : TerminalStream { + private val stdin: OutputStream = cmd.outputStream + private val io: ExecutorService = Executors.newSingleThreadExecutor { r -> + Thread(r, "nodeterm-ssh-writer").apply { isDaemon = true } + } + @Volatile private var ended = false + @Volatile private var composedRetired = false + override fun retireComposed() { composedRetired = true } + + fun start() { + Thread({ + val buf = ByteArray(16 * 1024) + val input = cmd.inputStream + try { + while (true) { + val n = input.read(buf) + if (n < 0) break + if (n > 0) sink.onOutput(buf.copyOf(n)) + } + } catch (_: Exception) { + // channel closed + } + runCatching { cmd.join(2, TimeUnit.SECONDS) } + ended = true + io.shutdown() + sink.onExit(cmd.exitStatus) + runCatching { session.close() } + }, "nodeterm-ssh-stream").apply { isDaemon = true }.start() + } + + /** Queue [block] on the stream's writer; a stream that has ended drops it. */ + private fun enqueue(block: () -> Unit) { + if (ended) return + try { + io.execute { + if (ended) return@execute + try { + block() + } catch (e: java.io.IOException) { + if (!isConnected) fireClosed(e.message) + // else: this channel closed under us; the reader thread reports the exit. + } catch (e: Throwable) { + ended = true + breakTransport(e) + } + } + } catch (_: RejectedExecutionException) { + // detached or exited: nothing left to write to + } + } + + override fun write(text: String) { + val bytes = text.toByteArray(Charsets.UTF_8) + enqueue { + stdin.write(bytes) + stdin.flush() + } + } + + override suspend fun submitComposed(input: ComposedInput): ComposedInputResult { + val result = CompletableDeferred() + val viewer = inputViewer ?: return ComposedInputResult.refused("Couldn't verify this terminal for Send. Reattach and try again; the draft was kept.") + if (!input.valid() || ended || composedRetired) return ComposedInputResult.refused() + val normalized = input.normalized() + try { + // The SAME writer executor follows every accepted raw write/resize/wheel. No side + // channel may overtake a reserved scroll, and nothing retargets a replacement stream. + io.execute { + if (ended || composedRetired) { result.complete(ComposedInputResult.refused()); return@execute } + var pasted = false + val answer = try { + fun dispatch(part: ComposedInput): ComposedInputResult { + val (code, raw) = run(SshComposedInput.send(nodeId, socket, phoneCreation, viewer, part), + timeoutSec = 10, stdin = if (part is ComposedInput.Paste) part.text else null, + uncertainWrite = true, outputLimit = 256) + return when { + code == 0 && raw.trim() == "nt-composed-refused" -> ComposedInputResult.refused() + code == 0 && raw.trim() == "nt-composed-delivered" && !ended && !composedRetired -> ComposedInputResult.DELIVERED + else -> ComposedInputResult.uncertain() + } + } + if (normalized is ComposedInput.Paste && normalized.text.isNotEmpty() && normalized.enter) { + val paste = dispatch(normalized.copy(enter = false)) + if (paste.status != ComposedInputResult.Status.DELIVERED) paste else { + pasted = true + // Preserve the original input bar's paste/Enter separation. Recheck + // the entire captured native receipt after this interval, never retarget. + Thread.sleep(150) + if (ended || composedRetired) ComposedInputResult.uncertain() + else dispatch(ComposedInput.Paste("", enter = true)).let { + if (it.status == ComposedInputResult.Status.DELIVERED) it else ComposedInputResult.uncertain() + } + } + } else dispatch(normalized) + } catch (_: HostUnansweredException) { ComposedInputResult.uncertain() } + catch (error: HostException) { if (pasted) ComposedInputResult.uncertain() else ComposedInputResult.refused(error.message ?: "Send was refused. The draft was kept.") } + catch (_: Exception) { ComposedInputResult.uncertain() } + result.complete(answer) + } + } catch (_: RejectedExecutionException) { return ComposedInputResult.refused() } + return result.await() + } + + override fun resize(cols: Int, rows: Int) { + // The exec'd channel is a SessionChannel, which is also a Session.Shell (the window-change owner). + enqueue { (session as Session.Shell).changeWindowDimensions(cols.coerceAtLeast(1), rows.coerceAtLeast(1), 0, 0) } + } + + /** The same SGR wheel event the relay host writes (host-service.ts `handleScroll`): tmux's + * mouse is on, so the wheel enters copy-mode and scrolls its own history. */ + override suspend fun scroll(up: Boolean, lines: Int) { + val seq = "\u001b[<${if (up) 64 else 65};1;1M" + write(seq.repeat(lines.coerceIn(1, 20))) + } + + override suspend fun searchHistory(query: String): dev.nodeterm.protocol.model.TerminalHistory.Result = withContext(Dispatchers.IO) { + if (ended) throw HostException("This terminal is no longer attached.") + if (!dev.nodeterm.protocol.model.TerminalHistory.validQuery(query)) throw HostException("Enter a single-line search of 1–256 characters.") + val creation = if (PhoneTerminals.validId(nodeId)) ownedPhoneCreation(nodeId) else null + val (code, out) = run(SshScripts.searchHistory(nodeId, query, socket, creation)) + if (ended) throw HostException("This terminal detached while its history was searched.") + if (code != 0) throw HostException(if (code == 4) "Retained history exceeds the 50 MiB search limit." else "The computer could not capture this terminal's history.") + dev.nodeterm.protocol.model.TerminalHistory.parseSsh(out, query) ?: throw HostException("The computer returned an invalid history search result.") + } + + override suspend fun detach() { + // Close BEHIND the writes already queued, so a keystroke typed just before leaving lands. + val done = CompletableDeferred() + try { + io.execute { + runCatching { session.close() } + done.complete(Unit) + } + io.shutdown() + withTimeoutOrNull(3_000) { done.await() } + } catch (_: RejectedExecutionException) { + // already ended + } + ended = true + withContext(Dispatchers.IO) { runCatching { session.close() } } + } + + override suspend fun endSession() { + if (socket != TmuxNames.PHONE_SOCKET) { + throw HostException("Ending a session needs the relay connection (it also removes the node from the canvas).") + } + killSession(nodeId) + detach() + } + } + + /** + * An exception escaped sshj's write path that was not a plain I/O failure: the packet sequence + * number and cipher may already have advanced, so every later packet would be garbage to the + * server. Tear the transport down now and say so, rather than leaving a connection that looks + * alive until its next packet. + */ + private fun breakTransport(e: Throwable) { + disconnectQuietly() + fireClosed("the SSH connection broke (${e.message ?: e.javaClass.simpleName})") + } + + /** `client.disconnect()`, and if that could not finish (its own write failed), the socket itself. */ + private fun disconnectQuietly() { + try { + client.disconnect() + } catch (_: Throwable) { + // fall through: make sure the socket and sshj's reader thread do not leak + } + // Idempotent, and `Socket.isConnected` stays true after a close, so do not gate on it. + runCatching { client.socket?.close() } + } + + private suspend fun action(method: String, projectId: String? = null, nodeId: String? = null, params: JsonObject): JsonObject = + withContext(Dispatchers.IO) { + ensureListed() + val listing = actionListing + val result = actions.call(listing.userData, method, params, nodeId ?: projectId ?: "") { SshActions.owns(listing.projects, projectId, nodeId, it) } + result as? JsonObject ?: SshActions.uncertain() + } + private fun JsonObject.flag(name: String): Boolean = + b(name) ?: SshActions.uncertain() + private suspend fun nudge(method: String, nodeId: String, title: String? = null) { + val result = action(method, nodeId = nodeId, params = buildJsonObject { + put("nodeId", nodeId); title?.let { put("title", it) } + }) + if (!result.flag("delivered")) throw HostException("The desktop did not deliver this action to the session.") + } + override suspend fun wake(nodeId: String) { nudge("node.wake", nodeId) } + override suspend fun refresh(nodeId: String) { nudge("node.refresh", nodeId) } + override suspend fun rename(nodeId: String, title: String) { nudge("node.rename", nodeId, title) } + override suspend fun ensureBoard(projectId: String): List { + val result = action("projects.ensureBoard", projectId, params = buildJsonObject { put("projectId", projectId) }) + val columns = result["columns"] as? JsonArray ?: SshActions.uncertain() + return columns.map { entry -> + val c = entry as? JsonObject ?: SshActions.uncertain() + KanbanColumn(c.s("id") ?: SshActions.uncertain(), c.s("title") ?: SshActions.uncertain(), c.s("color")) + } + } + override suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean = + action("projects.setCardColumn", projectId, nodeId, buildJsonObject { + put("projectId", projectId); put("nodeId", nodeId); put("columnId", columnId?.let(::JsonPrimitive) ?: JsonNull) + }).flag("moved") + override suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult { + val result = action("projects.editCardLabels", projectId, nodeId, buildJsonObject { + put("projectId", projectId); put("nodeId", nodeId) + put("add", JsonArray(edit.add.map(::JsonPrimitive))); put("remove", JsonArray(edit.remove.map(::JsonPrimitive))) + put("create", JsonArray(edit.create.map { (name, color) -> buildJsonObject { put("name", name); put("color", color) } })) + }) + val labels = result["labels"] as? JsonArray ?: SshActions.uncertain() + val card = result["cardLabelIds"] as? JsonArray ?: SshActions.uncertain() + return LabelEditResult(result.flag("edited"), labels.map { entry -> + val l = entry as? JsonObject ?: SshActions.uncertain() + KanbanLabel(l.s("id") ?: SshActions.uncertain(), l.s("name") ?: SshActions.uncertain(), l.s("color") ?: SshActions.uncertain()) + }, card.map { (it as? JsonPrimitive)?.takeIf { it.isString }?.content ?: SshActions.uncertain() }) + } + override suspend fun registerNode(projectId: String, node: NewNode): Boolean = relayOnly("Starting a new session") + override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement = withContext(Dispatchers.IO) { + ensureListed() + SshGit(cwd, gitRoots) { script, write -> + run(script, timeoutSec = if (write) 180 else 30, uncertainWrite = write, outputLimit = SshGitScripts.MAX_OUTPUT) + }.request(verb, args) + } + + private fun relayOnly(what: String): Nothing = + throw HostException(LegRouting.sshRefusal(what)) + + override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome = withContext(Dispatchers.IO) { + ensureListed() + refuseRemoteNode(event.nodeId) + val pendingId = event.pendingId ?: return@withContext ApprovalOutcome.UNSUPPORTED + if (!SshScripts.PENDING_ID.matches(pendingId)) return@withContext ApprovalOutcome.UNSUPPORTED + when (run(SshScripts.answerApproval(pendingId, allow)).second.trim()) { + "sent" -> ApprovalOutcome.SENT + "gone" -> ApprovalOutcome.GONE + else -> throw HostException("Couldn't write the answer on the computer.") + } + } + + override suspend fun rememberApproval(event: InboxEvent, suggestionIndex: Int): ApprovalOutcome = + answerHook(event, event.pendingId) { HookReplies.remember(it, suggestionIndex) } + + override suspend fun answerQuestions(event: InboxEvent, selections: List>): ApprovalOutcome = + answerHook(event, event.questionPendingId) { HookReplies.answerQuestions(it, selections) } + + private suspend fun answerHook(event: InboxEvent, ticket: String?, build: (JsonObject) -> String?): ApprovalOutcome = withContext(Dispatchers.IO) { + ensureListed() + refuseRemoteNode(event.nodeId) + if (ticket == null || !HookReplies.belongsToNode(event.nodeId, ticket)) return@withContext ApprovalOutcome.UNSUPPORTED + val (code, out) = run(SshScripts.readHookRequest(event.nodeId, ticket)) + if (code != 0) throw HostException("Couldn't read the held question or approval.") + if (out.trim() == "gone") return@withContext ApprovalOutcome.GONE + val split = out.indexOf('\n') + if (split < 0) throw HostException("The computer sent an invalid held request.") + val checksum = out.substring(0, split) + val raw = out.substring(split + 1) + if (!Regex("^[0-9]+ [0-9]+$").matches(checksum) || raw.toByteArray(Charsets.UTF_8).size > HookReplies.MAX_BYTES) + throw HostException("The held request exceeds the supported limit.") + val request = J.obj(J.parse(raw)) ?: throw HostException("The computer sent an invalid held request.") + val reply = build(request) ?: return@withContext ApprovalOutcome.UNSUPPORTED + val (writeCode, result) = run(SshScripts.answerHook(event.nodeId, ticket, checksum), stdin = reply, uncertainWrite = true, outputLimit = 1024) + if (writeCode != 0) throw HostException("Couldn't write the answer on the computer.") + when (result.trim()) { + "sent" -> ApprovalOutcome.SENT + "gone" -> ApprovalOutcome.GONE + else -> throw HostException("The computer did not confirm the answer.") + } + } + + /** The phone→host read-ack (src/core/ack-sweep.ts): `~/.nodeterm/acks/.seen`. */ + override suspend fun ackRead(nodeId: String, eventId: String?) { + withContext(Dispatchers.IO) { + runCatching { + ensureListed() + // A remote node's read-ack belongs on ITS host; writing it here would ack nothing. + if (!remoteNodes.containsKey(nodeId)) run(SshScripts.ackRead(nodeId, eventId ?: "")) + } + } + } + + override suspend fun sendKeys(nodeId: String, keys: String) { + withContext(Dispatchers.IO) { + ensureListed() + refuseRemoteNode(nodeId) + val creation = if (PhoneTerminals.validId(nodeId)) ownedPhoneCreation(nodeId) else null + val (code, _) = run(SshScripts.sendKeys(nodeId, keys, socketFor(nodeId), creation)) + if (code != 0) throw HostException("Couldn't type into the session (tmux exited ${code ?: "without a status"}).") + } + } + + override suspend fun paneCommand(nodeId: String): String? = withContext(Dispatchers.IO) { + try { + ensureListed() + // A remote node's pane is on ITS host; this computer's tmux has nothing to say about it. + if (remoteNodes.containsKey(nodeId)) return@withContext null + val creation = if (PhoneTerminals.validId(nodeId)) ownedPhoneCreation(nodeId) else null + val (code, out) = run(SshScripts.paneCommand(nodeId, socketFor(nodeId), creation)) + out.trim().takeIf { code == 0 && it.isNotEmpty() && '\n' !in it } + } catch (e: HostException) { + null // the transport dropped; `run` has already reported it through onClosed + } catch (e: IllegalArgumentException) { + null // not a node id this app generates: no tmux target is ever built from it + } + } + + /** Kill the node's tmux session (the node stays on the canvas; the desktop shows it as ended). */ + suspend fun killSession(nodeId: String) = withContext(Dispatchers.IO) { + ensureListed() + refuseRemoteNode(nodeId) + val creation = if (PhoneTerminals.validId(nodeId)) ownedPhoneCreation(nodeId) else null + val (code, _) = run(SshScripts.killSession(nodeId, socketFor(nodeId), creation)) + if (code != 0) throw HostException("Couldn't end the session (tmux exited ${code ?: "without a status"}).") + } + + /** `~/.nodeterm/relay.json`, when the computer advertises its relay identity (late adoption). */ + suspend fun readRelayAdvertisement(): JsonObject? = withContext(Dispatchers.IO) { + J.obj(J.parse(run(SshScripts.readRelayAdvertisement()).second)) + } + + override fun setOnChanged(listener: (() -> Unit)?) { + // SSH has no push channel; the UI polls (every 8 s while foregrounded, like iOS). + } + + override fun setOnClosed(listener: ((String?) -> Unit)?) { + onClosed = listener + } + + /** + * Intentional close. Never on the caller's thread: it is called from click handlers (Forget, a + * route change, re-pairing) on the Android main thread, where `SSH_MSG_DISCONNECT` would throw + * `NetworkOnMainThreadException` before sshj closes the socket — leaking it and its reader thread. + */ + override fun close() { + closedFired = true // an intentional close is not a drop + Thread({ disconnectQuietly() }, "nodeterm-ssh-close").apply { isDaemon = true }.start() + } + + companion object { + /** A dead peer is noticed within about interval × missed (≈45 s). */ + const val KEEPALIVE_INTERVAL_SEC = 15 + const val KEEPALIVE_MAX_MISSED = 3 + + /** One daemon timer for every [run] deadline. */ + private val WATCHDOG = java.util.concurrent.Executors.newSingleThreadScheduledExecutor { r -> + Thread(r, "nodeterm-ssh-watchdog").apply { isDaemon = true } + } + + /** What the browse looked for and did not find; [NothingFoundException] adds what to do about it. */ + const val NOT_FOUND = "nodeterm's data wasn't found on this computer over SSH. The phone looked for the " + + "desktop app's (~/Library/Application Support/node-terminal, ~/.config/node-terminal), the Server Edition's " + + "(~/.nodeterm-server), and sessions a desktop runs here over SSH. A Server Edition started with --data-dir " + + "or NODETERM_DATA_DIR somewhere else is not found this way." + + /** [NOT_FOUND] for a computer this phone can also reach through the relay. */ + const val NO_USER_DATA = "$NOT_FOUND Open nodeterm on the computer once, or connect through the relay." + + /** [NOT_FOUND] for a paired computer with no relay leg to offer (remote access isn't set up, or is off). */ + const val NO_USER_DATA_NO_RELAY = "$NOT_FOUND Open nodeterm on the computer once." + + /** + * [NOT_FOUND] for a computer added by its SSH address (audit A27): no relay, ever, and the user + * typed the login, so the wrong user is a likely cause; a dev host shows a driving desktop's + * sessions only once that desktop runs one there. + */ + const val NO_USER_DATA_ADDED_OVER_SSH = "$NOT_FOUND Check that the computer was added as the user nodeterm " + + "runs as there. If nodeterm on another computer runs sessions here over SSH, they show once one is running." + + /** A driven project's session that is not running (audit A27): only its own desktop starts it. */ + const val DRIVEN_NOT_RUNNING = "This session isn't running on this computer. It belongs to nodeterm on another " + + "computer, which runs it here over SSH: open it there to start it again." + + /** A session that is not running, of a node the computer's listing does not name (review of A27a). */ + const val NOT_RUNNING_UNLISTED = "This session isn't running on this computer, and nodeterm on this computer " + + "doesn't list it, so the phone has nothing to start it from. If nodeterm on another computer runs it here " + + "over SSH, open it there." + + /** + * Whether [e] is the server REFUSING this phone's key (or this user), as opposed to an + * authentication that could not finish. sshj 0.39 reports both as a [UserAuthException]: + * `SSHClient.auth` throws "Exhausted available authentication methods" whenever no method + * succeeded, and its cause is what stopped the last one — `UserAuthImpl`'s promise chains an + * auth timeout (`TimeoutException`) and a transport error delivered while it waited + * (`TransportException`, a socket `IOException`) into a [UserAuthException]. A clean refusal + * (the server answered `USERAUTH_FAILURE`) has nothing but [UserAuthException]s in its chain. + * Telling a user whose VPN dropped mid-login that the computer refused the key sends them to + * add the key again instead of checking the network. + */ + internal fun isAuthRefusal(e: Throwable): Boolean = e is UserAuthException && authFailureCause(e) == null + + /** The first cause behind [e]'s [UserAuthException]s that is not one (a timeout, a dropped transport), if any. */ + internal fun authFailureCause(e: Throwable): Throwable? { + var c: Throwable? = e + var depth = 0 + while (c is UserAuthException && depth++ < MAX_CAUSE_DEPTH) c = c.cause + return c?.takeIf { it !is UserAuthException } + } + + private const val MAX_CAUSE_DEPTH = 16 + + /** + * `SHA256:` of the key blob as sshj encodes it: for a certificate, the + * certificate's own blob. What names a host key is [hostKeyFingerprint]. + */ + fun fingerprint(key: PublicKey): String { + val blob = Buffer.PlainBuffer().putPublicKey(key).compactData + val digest = MessageDigest.getInstance("SHA-256").digest(blob) + return "SHA256:" + Base64.getEncoder().withoutPadding().encodeToString(digest) + } + + /** + * The fingerprint that names a presented host key, as OpenSSH prints it: for a host CERTIFICATE + * (sshd's `HostCertificate`, which sshj negotiates whenever the server offers one), the key it + * certifies, which is how OpenSSH fingerprints a certificate too; for a plain key, [fingerprint]. + * It is what the desktop reports (it reads the plain `.pub` files, at pairing and in every relay + * report), so a certificate matches the pairing's keys and the relay's report by it, and it is + * what is pinned (review of A74-refresh): pinning the certificate's own blob made the pin a key + * the computer never reports, so every relay report seemed to say the key had changed, and a + * certificate reissued for the same key (they are often short-lived) would have been a changed + * key. The server proves it holds that key's private half in the key exchange either way. + */ + fun hostKeyFingerprint(key: PublicKey): String = + (key as? Certificate<*>)?.let { cert -> runCatching { fingerprint(cert.key) }.getOrNull() } ?: fingerprint(key) + + fun connect( + host: String, + port: Int, + user: String, + identity: SshIdentity, + pin: HostKeyPin, + connectTimeoutMs: Int = 8_000, + socketFactory: SocketFactory? = null, + profilePath: String? = null + ): SshHostConnection { + SshProfilePath.requireValid(profilePath) + // KEEP_ALIVE, not sshj's default HEARTBEAT: a heartbeat is an SSH_MSG_IGNORE that expects + // no reply, so it never notices a dead peer. keepalive@openssh.com wants a reply and + // kills the transport after [KEEPALIVE_MAX_MISSED] misses, which fires the disconnect + // listener below → onClosed → the owner reconnects or falls back (audit A31). + val client = SSHClient(DefaultConfig().apply { keepAliveProvider = KeepAliveProvider.KEEP_ALIVE }) + if (socketFactory != null) client.socketFactory = socketFactory + var mismatch: HostKeyChangedException? = null + // The key this connection's server presented, held until authentication proves it is + // the computer we paired with; only then does it become the pin (audit A49). + var presented: String? = null + // A pin an older build took from a host certificate's own blob, which this server's + // certificate matched: re-spelled as the certified key once we are in (review of A74-refresh). + var certPin: String? = null + client.addHostKeyVerifier(object : HostKeyVerifier { + override fun verify(hostname: String, port: Int, key: PublicKey): Boolean { + val fp = hostKeyFingerprint(key) + // One server per connection: a re-key must present the same key as the first + // exchange, pinned or not. + val expected = pin.pinned() ?: presented + return when { + expected == null -> { + // Not pinned yet: a key the pairing named, if it named any (A49-anchor). + // Refused here, during the key exchange, so a stranger never sees our key. + val anchors = pin.anchors() + if (anchors.isEmpty() || fp in anchors) { + presented = fp + true + } else { + mismatch = HostKeyNotPairedException(anchors, fp) + false + } + } + expected == fp -> { + presented = fp + true + } + key is Certificate<*> && expected == fingerprint(key) -> { + presented = fp + certPin = expected + true + } + else -> { + mismatch = HostKeyChangedException(expected, fp) + false + } + } + } + + override fun findExistingAlgorithms(hostname: String, port: Int): List = emptyList() + }) + client.connectTimeout = connectTimeoutMs + client.timeout = 30_000 + try { + client.connect(host, port) + // Keys and wheel events are small packets. Nagle otherwise holds later input + // behind an unacknowledged packet, adding visible stalls over a mobile VPN. + client.socket.tcpNoDelay = true + val kp = identity.keyPair + client.authPublickey(user, object : KeyProvider { + override fun getPrivate(): PrivateKey = kp.private + override fun getPublic(): PublicKey = kp.public + override fun getType(): KeyType = KeyType.ED25519 + }) + // Authenticated: this server accepted the key the pairing installed, so its host key + // is the computer's. A server that refused us never got here and pinned nothing. + val seen = presented + val pinned = pin.pinned() + if (seen != null && (pinned == null || (pinned == certPin && pinned != seen))) pin.pin(seen) + client.connection.keepAlive.keepAliveInterval = KEEPALIVE_INTERVAL_SEC + (client.connection.keepAlive as? KeepAliveRunner)?.maxAliveCount = KEEPALIVE_MAX_MISSED + } catch (e: Exception) { + runCatching { client.disconnect() } + if (client.isConnected) runCatching { client.socket?.close() } + mismatch?.let { throw it } + if (isAuthRefusal(e)) { + throw SshAuthRefusedException( + "Couldn't connect over SSH to $user@$host:$port: it did not accept this phone's key " + + "(${e.message ?: e.javaClass.simpleName})." + ) + } + // An authentication that could not FINISH is a connection failure: say what stopped it + // (the timeout, the dropped transport), not sshj's "Exhausted available authentication + // methods" wrapped around it. + val why = authFailureCause(e) ?: e + throw HostException("Couldn't connect over SSH to $user@$host:$port (${why.message ?: why.javaClass.simpleName}).") + } + val conn = SshHostConnection(client, profilePath) + // The transport dying (network change, sleep, the computer going away) is the one event + // nothing else would report: without this the owner keeps a dead connection forever. + client.transport.disconnectListener = net.schmizz.sshj.transport.DisconnectListener { _, message -> + conn.fireClosed(message) + } + return conn + } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshPasswordBootstrap.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshPasswordBootstrap.kt new file mode 100644 index 0000000000..0ee52480e8 --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshPasswordBootstrap.kt @@ -0,0 +1,319 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.HostUnansweredException +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.pairing.SshIdentity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.transport.verification.HostKeyVerifier +import net.schmizz.sshj.userauth.method.AuthPassword +import net.schmizz.sshj.userauth.password.PasswordFinder +import net.schmizz.sshj.userauth.password.Resource +import java.io.IOException +import java.net.InetAddress +import java.net.Socket +import java.security.PublicKey +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import javax.net.SocketFactory +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Optional, password-only enrollment for a computer added by SSH address. Inspection sends no + * authentication. The caller must show the fingerprint and obtain the user's confirmation before + * calling [Inspection.confirm]. No password, private key or SSH identity is persisted here. + * + * Enrollment installs only the supplied retained identity's public line. It closes the password + * connection and proves a new, pinned public-key login before returning a record the caller may save. + * Keyboard-interactive, MFA and password changes are deliberately not attempted. + */ +object SshPasswordBootstrap { + const val INSPECT_TIMEOUT_MS = 15_000L + const val INSTALL_TIMEOUT_MS = 45_000L + private const val MAX_TIMEOUT_MS = 60_000L + private const val MAX_OUTPUT = 4096 + private val workers = Executors.newCachedThreadPool { r -> + Thread(r, "nodeterm-ssh-enrollment").apply { isDaemon = true } + } + + class Inspection internal constructor(val address: ManualHost.Address, val fingerprint: String) { + /** Call only following an explicit user confirmation of this displayed fingerprint. */ + fun confirm(fingerprint: String): ConfirmedHost { + require(fingerprint == this.fingerprint) { "Confirm the fingerprint inspected for this computer." } + return ConfirmedHost(address, fingerprint) + } + } + + class ConfirmedHost internal constructor(val address: ManualHost.Address, val fingerprint: String) { + internal val used = AtomicBoolean(false) + } + + /** Connect and complete key exchange, then close without offering any authentication method. */ + suspend fun inspect( + address: ManualHost.Address, + timeoutMs: Long = INSPECT_TIMEOUT_MS, + socketFactory: SocketFactory? = null + ): Inspection = operation(timeoutMs, { HostException("SSH host key inspection timed out. ${ManualHost.UNREACHABLE_ADVICE}") }) { resources -> + val observed = VerifiedKey(null) + val client = resources.client(observed, socketFactory, timeoutMs) + try { + client.connect(address.host, address.port) + resources.check() + Inspection(address, observed.fingerprint ?: throw HostException("The SSH server did not present a host key.")) + } catch (e: Exception) { + observed.mismatch?.let { throw it } + if (e is HostException || e is CancellationException) throw e + throw HostException("Could not inspect this computer's SSH host key. ${ManualHost.UNREACHABLE_ADVICE}") + } + } + + /** + * Consumes [confirmed] once and clears the caller's [password] buffer, including on failure. + * Passwords remain memory-only; the SSH library/JVM may make transient copies that cannot be erased. + * A lost command acknowledgement is uncertain and is never replayed. Check the computer or try a + * normal key connection instead. Cancelling leaves any already-installed public key in place. + */ + suspend fun install( + confirmed: ConfirmedHost, + identity: SshIdentity, + password: CharArray, + id: String = ManualHost.newId(), + now: Long = System.currentTimeMillis(), + timeoutMs: Long = INSTALL_TIMEOUT_MS, + socketFactory: SocketFactory? = null + ): PairedHost { + val secret = password.copyOf() + password.fill('\u0000') + try { + require(secret.isNotEmpty() && secret.size <= 4096) { "Enter a password of at most 4096 characters." } + require(HostKeyAnchors.isFingerprint(confirmed.fingerprint)) { "Confirm a valid SSH host fingerprint first." } + check(confirmed.used.compareAndSet(false, true)) { "This enrollment was already attempted. Inspect and confirm again before another attempt." } + val dispatched = AtomicBoolean(false) + return operation(timeoutMs, { + if (dispatched.get()) HostUnansweredException(UNCONFIRMED) + else HostException("Password enrollment timed out. ${ManualHost.UNREACHABLE_ADVICE}") + }) { resources -> + val address = confirmed.address + val observed = VerifiedKey(confirmed.fingerprint) + val client = resources.client(observed, socketFactory, timeoutMs) + try { + client.connect(address.host, address.port) + resources.check() + // SSHClient.authPassword also offers keyboard-interactive. Use password only, + // once, and never answer an unsolicited MFA or password-change prompt. + client.auth(address.user, AuthPassword(object : PasswordFinder { + private var offered = false + override fun reqPassword(resource: Resource<*>): CharArray { + resources.check() + check(!offered) { "The server requested another password attempt." } + offered = true + return secret + } + override fun shouldRetry(resource: Resource<*>): Boolean = false + })) + secret.fill('\u0000') + resources.check() + client.startSession().use { session -> + // Even loss of the exec acknowledgement may mean the write ran. + dispatched.set(true) + val command = session.exec(installCommand(identity)) + command.outputStream.close() + // Both windows must drain: a server writing stderr first must not stall + // stdout. Each is capped and none of the server's text is surfaced. + val errors = workers.submit { drain(command.errorStream) } + try { + drain(command.inputStream) + errors.get() + } finally { + errors.cancel(true) + } + command.join(5, TimeUnit.SECONDS) + resources.check() + if (command.exitStatus == null) throw IOException("The computer did not confirm the key installation.") + if (command.exitStatus != 0) throw HostException("The computer could not install this phone's public key. Check ~/.ssh and authorized_keys permissions on the computer.") + } + // Independent public-key authentication, with the confirmed host pin. Never + // fall back to the password or pin the first server accepting the new key. + resources.close(client) + resources.check() + val pin = object : HostKeyPin { + override fun pinned(): String = confirmed.fingerprint + override fun pin(fingerprint: String) = Unit + } + SshHostConnection.connect(address.host, address.port, address.user, identity, pin, + timeoutMs.toInt(), resources.sockets(socketFactory)).use { resources.check() } + resources.check() + ManualHost.record(address, confirmed.fingerprint, id, now) + } catch (e: Exception) { + observed.mismatch?.let { throw it } + if (e is CancellationException || e is HostKeyChangedException) throw e + if (dispatched.get() && e !is HostException) throw HostUnansweredException(UNCONFIRMED) + if (dispatched.get()) throw HostException( + "Enrollment did not complete a new login with the phone's key. The public key may already be installed; no computer was saved. Check ~/.ssh and authorized_keys permissions on the computer." + ) + if (SshHostConnection.isAuthRefusal(e)) throw HostException( + "The computer did not accept the password. Password-only SSH login must be enabled; keyboard-interactive and MFA are not supported." + ) + if (e is HostException) throw e + throw HostException("Password enrollment could not connect. ${ManualHost.UNREACHABLE_ADVICE}") + } + } + } finally { + secret.fill('\u0000') + } + } + + private const val UNCONFIRMED = "The computer did not confirm enrollment. Its public key may already be installed. Try connecting with the phone's key or check authorized_keys on the computer; this attempt will not be repeated." + + private fun drain(input: java.io.InputStream) { + val buffer = ByteArray(512) + var size = 0 + while (true) { + val n = input.read(buffer) + if (n < 0) return + size += n + if (size > MAX_OUTPUT) throw IOException("Enrollment output exceeded its limit.") + } + } + + /** Fixed public-key command; the password and private seed never enter shell text or stdin. */ + internal fun installCommand(identity: SshIdentity): String { + // GNU/Linux and BSD/macOS stat spell these public inode checks differently. Neither reads + // any existing key contents; reject unsafe paths before the legacy manual command's chmod. + val guard = "nt_uid=\$(id -u) || exit 2; " + + "nt_owner() { stat -c %u \"\$1\" 2>/dev/null || stat -f %u \"\$1\" 2>/dev/null; }; " + + "nt_links() { stat -c %h \"\$1\" 2>/dev/null || stat -f %l \"\$1\" 2>/dev/null; }; " + + "[ ! -L \"\$HOME/.ssh\" ] && " + + "{ [ ! -e \"\$HOME/.ssh\" ] || { [ -d \"\$HOME/.ssh\" ] && [ \"\$(nt_owner \"\$HOME/.ssh\")\" = \"\$nt_uid\" ]; }; } && " + + "[ ! -L \"\$HOME/.ssh/authorized_keys\" ] && " + + "{ [ ! -e \"\$HOME/.ssh/authorized_keys\" ] || { [ -f \"\$HOME/.ssh/authorized_keys\" ] && " + + "[ \"\$(nt_owner \"\$HOME/.ssh/authorized_keys\")\" = \"\$nt_uid\" ] && " + + "[ \"\$(nt_links \"\$HOME/.ssh/authorized_keys\")\" = 1 ]; }; } || exit 2; " + val blob = ManualHost.authorizedKeysLine(identity).split(' ')[1] + // Only the algorithm/blob immediately after the first optional-options token is a key. + // A later comment must never suppress installation; an existing restricted declaration + // must never cause an unrestricted duplicate. Unterminated options fail closed. + val scanner = """ + function boundary(s, i, quoted, escaped, c) { + quoted=0; escaped=0; + for (i=1; i<=length(s); i++) { + c=substr(s,i,1); + if (escaped) { escaped=0; continue; } + if (quoted && c=="\\") { escaped=1; continue; } + if (c=="\"") { quoted=!quoted; continue; } + if (!quoted && c ~ /[[:space:]]/) return i; + } + if (quoted || escaped) return -1; + return length(s)+1; + } + { + line=${'$'}0; sub(/^[[:space:]]+/,"",line); + if (line=="" || substr(line,1,1)=="#") next; + cut=boundary(line); + if (cut<0) { ambiguous=1; next; } + algorithm=substr(line,1,cut-1); + rest=substr(line,cut); sub(/^[[:space:]]+/,"",rest); + if (algorithm!="ssh-ed25519") { + if (algorithm ~ /^(ssh-|ecdsa-|sk-)/) next; + cut=boundary(rest); + if (cut<0) { ambiguous=1; next; } + algorithm=substr(rest,1,cut-1); + rest=substr(rest,cut); sub(/^[[:space:]]+/,"",rest); + } + cut=boundary(rest); + if (algorithm=="ssh-ed25519" && cut>0 && substr(rest,1,cut-1)==nt_key) found=1; + } + END { if (ambiguous) exit 2; exit !found; } + """.trimIndent() + val existing = "if [ -f \"\$HOME/.ssh/authorized_keys\" ]; then awk -v nt_key='$blob' " + + SshScripts.q(scanner) + " \"\$HOME/.ssh/authorized_keys\"; nt_scan=\$?; " + + "case \$nt_scan in 0) exit 0 ;; 1) ;; *) exit 2 ;; esac; fi; " + return "/bin/sh -c " + SshScripts.q(guard + existing + ManualHost.installCommand(ManualHost.authorizedKeysLine(identity))) + } + + private class VerifiedKey(private val expected: String?) : HostKeyVerifier { + var fingerprint: String? = null + var mismatch: HostKeyChangedException? = null + override fun verify(hostname: String, port: Int, key: PublicKey): Boolean { + val actual = SshHostConnection.hostKeyFingerprint(key) + val want = expected ?: fingerprint + if (want != null && want != actual) { + mismatch = HostKeyChangedException(want, actual) + return false + } + fingerprint = actual + return true + } + override fun findExistingAlgorithms(hostname: String, port: Int): List = emptyList() + } + + private class Resources { + private var cancelled = false + private val sockets = ArrayList() + private val clients = ArrayList() + @Synchronized fun check() { if (cancelled) throw CancellationException("SSH enrollment cancelled") } + @Synchronized private fun remember(socket: Socket): Socket { + if (cancelled) { socket.close(); throw CancellationException("SSH enrollment cancelled") } + sockets += socket + return socket + } + fun sockets(factory: SocketFactory?) = object : SocketFactory() { + private val delegate = factory ?: SocketFactory.getDefault() + override fun createSocket(): Socket = remember(delegate.createSocket()) + override fun createSocket(host: String, port: Int): Socket = remember(delegate.createSocket(host, port)) + override fun createSocket(host: String, port: Int, local: InetAddress, localPort: Int): Socket = remember(delegate.createSocket(host, port, local, localPort)) + override fun createSocket(host: InetAddress, port: Int): Socket = remember(delegate.createSocket(host, port)) + override fun createSocket(host: InetAddress, port: Int, local: InetAddress, localPort: Int): Socket = remember(delegate.createSocket(host, port, local, localPort)) + } + @Synchronized fun client(verifier: HostKeyVerifier, factory: SocketFactory?, timeoutMs: Long): SSHClient { + check() + return SSHClient().also { + it.socketFactory = sockets(factory) + it.addHostKeyVerifier(verifier) + it.connectTimeout = timeoutMs.toInt() + it.timeout = timeoutMs.toInt() + clients += it + } + } + fun close(client: SSHClient) { runCatching { client.close() } } + fun cancel() { + val owned = synchronized(this) { cancelled = true; sockets.toList() } + owned.forEach { runCatching { it.close() } } + } + fun finish() { + cancel() + clients.forEach(::close) + } + } + + private data class Completed(val value: T) + + private suspend fun operation(timeoutMs: Long, timeoutFailure: () -> HostException, work: (Resources) -> T): T { + require(timeoutMs in 1..MAX_TIMEOUT_MS) { "SSH enrollment timeout must be between 1 and $MAX_TIMEOUT_MS ms." } + return withTimeoutOrNull(timeoutMs) { + suspendCancellableCoroutine> { continuation -> + val resources = Resources() + var task: Future<*>? = null + continuation.invokeOnCancellation { resources.cancel(); task?.cancel(true) } + task = workers.submit { + try { + val result = work(resources) + resources.finish() + if (continuation.isActive) continuation.resume(Completed(result)) + } catch (e: Exception) { + if (continuation.isActive) continuation.resumeWithException(e) + } finally { + resources.finish() + } + } + if (!continuation.isActive) task?.cancel(true) + } + }?.value ?: throw timeoutFailure() + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshProfilePath.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshProfilePath.kt new file mode 100644 index 0000000000..ec8b63932c --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshProfilePath.kt @@ -0,0 +1,25 @@ +package dev.nodeterm.protocol.ssh + +/** An explicit remote POSIX profile path. Null retains automatic discovery. */ +object SshProfilePath { + const val MAX_LENGTH = 4096 + + fun error(path: String): String? = when { + !path.startsWith('/') -> "Use the full absolute profile folder path on the computer." + path.length > MAX_LENGTH -> "The profile folder path is too long." + path.any { it.isISOControl() } -> "The profile folder path cannot contain control characters." + path.split('/').any { it == "." || it == ".." } -> "Use the profile folder path without . or .. components." + else -> null + } + + /** Form input only: a blank field restores discovery; a saved invalid choice never does. */ + fun fromInput(input: String): String? { + if (input.isBlank()) return null + require(error(input) == null) { error(input)!! } + return input.trimEnd('/').ifEmpty { "/" } + } + + fun requireValid(path: String?) { + if (path != null) require(error(path) == null) { error(path)!! } + } +} diff --git a/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt new file mode 100644 index 0000000000..0ef809287d --- /dev/null +++ b/android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt @@ -0,0 +1,615 @@ +package dev.nodeterm.protocol.ssh + +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.host.ManagedSessionAdoption +import dev.nodeterm.protocol.model.TerminalHistory + +/** + * The POSIX sh the direct-SSH transport runs on the paired computer. Generated shell, so every + * interpolation is single-quoted with [q] and every target is checked against the shape this app + * generates (`nt-[A-Za-z0-9_-]+`) before it is spliced. + * + * Four facts about the computer these encode: + * - an ssh exec channel gets a NON-login shell, so Homebrew's tmux is not on PATH on a Mac: the + * PATH is APPENDED (never prepended — a PATH that already resolves tmux keeps that binary), the + * same rule as `remoteTmuxPathPrologue` in src/shared/ssh.ts; the tmux the macOS app ships + * (`Contents/Resources/bin/tmux`) is the last resort, as it is for the desktop's own `findTmux`; + * - a nodeterm running ON the computer keeps its sessions on the `-L node-terminal` socket + * (src/core/tmux-naming.ts), and a desktop that drives the computer over SSH keeps the sessions + * of its SSH projects on `-L nodeterm-rmt` (`remoteTmuxCommand`, src/shared/ssh.ts). Both can be + * there at once, so every session is reached on the socket it was listed on ([TmuxNames.SOCKETS], + * audit A27), and a session that was not listed is looked up on both ([whichSocket]); + * - the desktop app's userData is `~/Library/Application Support/node-terminal` on macOS and + * `$XDG_CONFIG_HOME/node-terminal` (default `~/.config/node-terminal`) on Linux: Electron names it + * after package.json `name`, and the desktop has no top-level `productName` (the desktop's own + * hook shell walks the same dirs, src/core/agents/hook-endpoint-failover-sh.ts). The `nodeterm` + * spelling is probed after it as a legacy fallback (audit A02); + * - the Server Edition keeps the same files in its data dir: `~/.nodeterm-server` by default + * (src/server/config.ts), or wherever `--data-dir` / `NODETERM_DATA_DIR` put it. The browse checks + * `$NODETERM_DATA_DIR` when the SSH session happens to carry it, then the default; a server whose + * data dir is elsewhere needs the phone's explicit saved profile folder selection. + */ +object SshScripts { + const val META_START = "##NT-META" + const val META_END = "##NT-META-END" + const val FILE_MARK = "##NT-FILE " + + /** [browse]'s sections after the `projects.list`-shaped part ([HostBrowse] reads them). Each is a + * line of its own; JSON never has a line starting `##`, and session names are checked in sh. */ + const val RMT_MARK = "##NT-RMT" + const val SLICE_MARK = "##NT-SLICE " + const val PROJECT_FILE_MARK = "##NT-PROJFILE " + const val PHONE_MARK = "##NT-PHONE" + const val END_MARK = "##NT-END" + + /** Bounds on what [browse] walks for project files: directories per session path, and files. */ + const val WALK_DEPTH = 40 + const val MAX_PROJECT_FILES = 64 + + /** Single-quote for sh: `'` → `'\''`. */ + fun q(s: String): String = "'" + s.replace("'", "'\\''") + "'" + + const val SELECTED_PROFILE_MISSING_EXIT = 46 + + private val PREPARE = """ + unset TMUX TMUX_PANE + PATH="${'$'}PATH:/opt/homebrew/bin:/usr/local/bin:/opt/local/bin:${'$'}HOME/.local/bin"; export PATH + NT_TMUX=${'$'}(command -v tmux 2>/dev/null) + if [ -z "${'$'}NT_TMUX" ]; then + for c in /Applications/nodeterm.app/Contents/Resources/bin/tmux "${'$'}HOME/Applications/nodeterm.app/Contents/Resources/bin/tmux"; do + if [ -x "${'$'}c" ]; then NT_TMUX="${'$'}c"; break; fi + done + fi + """.trimIndent() + + private val PRELUDE = """ + $PREPARE + NT_UD="" + for d in "${'$'}HOME/Library/Application Support/node-terminal" "${'$'}{XDG_CONFIG_HOME:-${'$'}HOME/.config}/node-terminal" \ + "${'$'}HOME/Library/Application Support/nodeterm" "${'$'}{XDG_CONFIG_HOME:-${'$'}HOME/.config}/nodeterm"; do + if [ -f "${'$'}d/workspace.json" ]; then NT_UD="${'$'}d"; break; fi + done + if [ -z "${'$'}NT_UD" ]; then + for d in ${'$'}{NODETERM_DATA_DIR:+"${'$'}NODETERM_DATA_DIR"} "${'$'}HOME/.nodeterm-server"; do + if [ -f "${'$'}d/workspace.json" ] || [ -f "${'$'}d/agent-status.json" ] || [ -f "${'$'}d/install-meta.json" ]; then NT_UD="${'$'}d"; break; fi + done + fi + """.trimIndent() + + private fun prelude(profilePath: String?): String { + SshProfilePath.requireValid(profilePath) + if (profilePath == null) return PRELUDE + // Keep tmux/PATH preparation, but never run automatic profile discovery for an explicit choice. + return """ + $PREPARE + NT_UD=${q(profilePath)} + [ -d "${'$'}NT_UD" ] || exit $SELECTED_PROFILE_MISSING_EXIT + if [ ! -f "${'$'}NT_UD/workspace.json" ] && [ ! -f "${'$'}NT_UD/agent-status.json" ] && [ ! -f "${'$'}NT_UD/install-meta.json" ]; then + exit $SELECTED_PROFILE_MISSING_EXIT + fi + """.trimIndent() + } + + /** Validate atomic ownership metadata, including sessions with interrupted option setup. */ + private val PHONE_GUARD = """ + nt_phone_owned() { + nt_name=${'$'}1 + nt_id_env=${'$'}("${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} show-environment -t "=${'$'}nt_name" ${PhoneTerminals.ID_ENV} 2>/dev/null) || return 1 + nt_id=${'$'}{nt_id_env#${PhoneTerminals.ID_ENV}=} + [ "${'$'}nt_name" = "nt-${'$'}nt_id" ] || return 1 + case "${'$'}nt_id" in phone-????????-????-????-????-????????????) ;; *) return 1 ;; esac + case "${'$'}{nt_id#phone-}" in *[!0-9a-f-]*) return 1 ;; esac + nt_marker=${'$'}("${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} show-environment -t "=${'$'}nt_name" ${PhoneTerminals.CREATION_ENV} 2>/dev/null) || return 1 + nt_creation=${'$'}{nt_marker#${PhoneTerminals.CREATION_ENV}=} + [ ${'$'}{#nt_creation} -eq 64 ] || return 1 + case "${'$'}nt_creation" in *[!0-9a-f]*) return 1 ;; esac + nt_cwd_env=${'$'}("${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} show-environment -t "=${'$'}nt_name" ${PhoneTerminals.CWD_ENV} 2>/dev/null) || return 1 + nt_cwd=${'$'}{nt_cwd_env#${PhoneTerminals.CWD_ENV}=} + case "${'$'}nt_cwd" in /*) ;; *) return 1 ;; esac + nt_request_env=${'$'}("${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} show-environment -t "=${'$'}nt_name" ${PhoneTerminals.REQUEST_ENV} 2>/dev/null) || return 1 + nt_request=${'$'}{nt_request_env#${PhoneTerminals.REQUEST_ENV}=} + [ "${'$'}nt_request" = HOME ] || [ "${'$'}nt_request" = "${'$'}nt_cwd" ] || return 1 + nt_options=${'$'}("${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} display-message -p -t "=${'$'}nt_name:" '#{${PhoneTerminals.ID_OPTION}}|#{${PhoneTerminals.CREATION_OPTION}}|#{${PhoneTerminals.CWD_OPTION}}' 2>/dev/null) || return 1 + nt_opt_id=${'$'}{nt_options%%|*}; nt_options=${'$'}{nt_options#*|} + nt_opt_creation=${'$'}{nt_options%%|*}; nt_opt_cwd=${'$'}{nt_options#*|} + [ -z "${'$'}nt_opt_id" ] || [ "${'$'}nt_opt_id" = "${'$'}nt_id" ] || return 1 + [ -z "${'$'}nt_opt_creation" ] || [ "${'$'}nt_opt_creation" = "${'$'}nt_creation" ] || return 1 + [ -z "${'$'}nt_opt_cwd" ] || [ "${'$'}nt_opt_cwd" = "${'$'}nt_cwd" ] || return 1 + return 0 + } + """.trimIndent() + + /** + * A UTF-8 locale for the tmux CLIENT (audit A03). An sshd exec channel on a stock macOS host + * carries no LANG, and tmux draws every character with no ACS mapping (╭, é, CJK, emoji) as `_` + * for a client it does not believe is UTF-8. The rule is the desktop's `resolveLocaleLang` + * (src/core/pty-manager.ts): keep an inherited LC_ALL/LC_CTYPE/LANG that already says UTF-8, + * otherwise export LANG — `en_US.UTF-8` on macOS (always present, and what the desktop uses), + * `C.UTF-8` elsewhere (a Linux host may not have en_US generated, and shells would print setlocale + * warnings). LANG also reaches the panes of a server this attach starts; `-u` on the client makes + * the rendering UTF-8 even when an explicit non-UTF-8 LC_ALL wins over LANG. + */ + private val LOCALE = """ + case "${'$'}{LC_ALL:-${'$'}{LC_CTYPE:-${'$'}{LANG:-}}}" in + *[Uu][Tt][Ff]-8*|*[Uu][Tt][Ff]8*) ;; + *) if [ "${'$'}(uname -s 2>/dev/null)" = Darwin ]; then LANG=en_US.UTF-8; else LANG=C.UTF-8; fi; export LANG ;; + esac + """.trimIndent() + + /** + * Browse: emits a meta block (the data dir, tmux, `$HOME`, and `relay=1|0`: whether + * `~/.nodeterm/relay.json` is there — the desktop writes it while its phone host is registered at + * the relay and removes it when remote access is turned off, relay-advertise.ts; audit A26), then + * EXACTLY the `projects.list` blob shape (workspace.json · + * live `nt-*` sessions on `node-terminal` · agent-status.json), so the relay and SSH paths share + * one parser — then what a desktop that drives this computer over SSH left here (audit A27): + * + * - [RMT_MARK]: the `nt-*` sessions on `nodeterm-rmt`, one name per line (checked against the + * shape this app generates before it is printed); + * - [SLICE_MARK]``: each `~/.nodeterm/agent-status-.json`, the per-project + * status slice that desktop pushes (src/main/remote-ssh/remote-status-push.ts). There is no + * workspace.json for those projects on this computer: this is their only status; + * - [PROJECT_FILE_MARK]``: each `/.nodeterm/project.json` found by walking up from a + * `nodeterm-rmt` session's start directory (`#{session_path}`, the node's cwd the desktop gave + * `new-session -c`), deduplicated, at most [WALK_DEPTH] levels per path and + * [MAX_PROJECT_FILES] files. An SSH project's canvas lives in `/.nodeterm/project.json`, + * and its sessions start in or under that folder; + * - [END_MARK]. + * + * Splitting is by lines in the shell (`IFS` = newline, globbing off), never by a here-document: + * this template keeps its indentation, and a here-document's terminator must start its line. + */ + fun browse(profilePath: String? = null): String = """ + ${prelude(profilePath)} + printf '%s\n' '$META_START' + printf 'ud=%s\n' "${'$'}NT_UD" + printf 'tmux=%s\n' "${'$'}NT_TMUX" + printf 'home=%s\n' "${'$'}HOME" + if [ -s "${'$'}HOME/.nodeterm/relay.json" ]; then printf 'relay=1\n'; else printf 'relay=0\n'; fi + printf '%s\n' '$META_END' + if [ -n "${'$'}NT_UD" ]; then cat "${'$'}NT_UD/workspace.json" 2>/dev/null; fi + printf '\n%s\n' '${ProjectsParser.PROJECTS_MARK}' + if [ -n "${'$'}NT_TMUX" ]; then "${'$'}NT_TMUX" -L ${TmuxNames.SOCKET} list-sessions -F '#{session_name}' 2>/dev/null; fi + printf '\n%s\n' '${ProjectsParser.STATUS_MARK}' + if [ -n "${'$'}NT_UD" ]; then cat "${'$'}NT_UD/agent-status.json" 2>/dev/null; fi + printf '\n%s\n' '$RMT_MARK' + NT_NL=${'$'}(printf '\nx'); NT_NL=${'$'}{NT_NL%x} + NT_DIRS=""; NT_NDIRS=0 + if [ -n "${'$'}NT_TMUX" ]; then + NT_RMT=${'$'}("${'$'}NT_TMUX" -L ${TmuxNames.REMOTE_SOCKET} list-sessions -F '#{session_name} #{session_path}' 2>/dev/null) + set -f; IFS=${'$'}NT_NL + for l in ${'$'}NT_RMT; do + n=${'$'}{l%% *} + case "${'$'}n" in nt-*) ;; *) continue ;; esac + case "${'$'}{n#nt-}" in ''|*[!A-Za-z0-9_-]*) continue ;; esac + printf '%s\n' "${'$'}n" + p=${'$'}{l#* } + [ "${'$'}p" = "${'$'}l" ] && continue + i=0 + while [ ${'$'}i -lt $WALK_DEPTH ] && [ ${'$'}NT_NDIRS -lt $MAX_PROJECT_FILES ]; do + case "${'$'}p" in /*) ;; *) break ;; esac + if [ -f "${'$'}p/.nodeterm/project.json" ]; then + case "${'$'}NT_NL${'$'}NT_DIRS${'$'}NT_NL" in + *"${'$'}NT_NL${'$'}p${'$'}NT_NL"*) ;; + *) NT_DIRS="${'$'}NT_DIRS${'$'}NT_NL${'$'}p"; NT_NDIRS=${'$'}((NT_NDIRS + 1)) ;; + esac + fi + [ "${'$'}p" = / ] && break + p=${'$'}{p%/*} + [ -n "${'$'}p" ] || p=/ + i=${'$'}((i + 1)) + done + done + unset IFS; set +f + fi + for f in "${'$'}HOME"/.nodeterm/agent-status-*.json; do + [ -f "${'$'}f" ] || continue + id=${'$'}{f##*/}; id=${'$'}{id#agent-status-}; id=${'$'}{id%.json} + case "${'$'}id" in ''|*[!A-Za-z0-9._-]*) continue ;; esac + printf '\n%s%s\n' '$SLICE_MARK' "${'$'}id" + cat "${'$'}f" 2>/dev/null + done + set -f; IFS=${'$'}NT_NL + for d in ${'$'}NT_DIRS; do + printf '\n%s%s\n' '$PROJECT_FILE_MARK' "${'$'}d" + cat "${'$'}d/.nodeterm/project.json" 2>/dev/null + done + unset IFS; set +f + printf '\n%s\n' '$PHONE_MARK' + if [ -n "${'$'}NT_TMUX" ]; then + $PHONE_GUARD + NT_PHONE=${'$'}("${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} list-sessions -F '#{session_name}' 2>/dev/null) + set -f; IFS=${'$'}NT_NL + for n in ${'$'}NT_PHONE; do + nt_phone_owned "${'$'}n" || continue + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "${'$'}n" "${'$'}nt_id" "${'$'}nt_creation" "${'$'}nt_cwd" "${'$'}nt_creation" "${'$'}nt_request" + done + unset IFS; set +f + fi + printf '\n%s\n' '$END_MARK' + exit 0 + """.trimIndent() + + /** Cat each file behind a `##NT-FILE ` marker (a missing file yields an empty section). */ + fun catFiles(paths: List): String = buildString { + for ((i, p) in paths.withIndex()) { + append("printf '\\n%s\\n' '").append(FILE_MARK).append(i).append("'\n") + append("cat ").append(q(p)).append(" 2>/dev/null\n") + } + append("exit 0\n") + } + + /** + * Which socket the node's session is on: prints `node-terminal` or `nodeterm-rmt` (the first that + * has it, in [TmuxNames.SOCKETS] order — the attribution [browse]'s listing makes), or nothing + * when neither does or there is no tmux. + */ + fun whichSocket(nodeId: String, phoneCreation: String? = null): String { + val target = q("=" + target(nodeId)) + return """ + $PRELUDE + $PHONE_GUARD + [ -n "${'$'}NT_TMUX" ] || exit 0 + for s in ${if (PhoneTerminals.validId(nodeId)) TmuxNames.PHONE_SOCKET else TmuxNames.SOCKETS.joinToString(" ")}; do + if "${'$'}NT_TMUX" -L "${'$'}s" has-session -t $target 2>/dev/null; then + if [ "${'$'}s" = ${TmuxNames.PHONE_SOCKET} ]; then + nt_phone_owned ${q(target(nodeId))} || continue + [ "${'$'}nt_creation" = ${q(phoneCreation ?: "")} ] || continue + fi + printf '%s\n' "${'$'}s"; exit 0 + fi + done + exit 0 + """.trimIndent() + } + + /** + * Attach a pty to the node's EXISTING session — `attach-session`, never `new-session`: a session + * created over SSH gets none of the hook environment the desktop gives it (`NODETERM_NODE_ID`, + * the hook endpoint), so an agent resumed there never reports status, and the desktop never + * repairs it because tmux reads `-e` only at creation — or it inherits ANOTHER node's id from the + * tmux server's global env (audit A08). A missing session exits [NO_SESSION_EXIT] instead, and + * the app offers the relay, where the desktop creates it properly. Without `-d`: the desktop's + * own client must stay attached (`-d` is what "[detached]" dead terminals are made of). + * + * The same holds on [TmuxNames.REMOTE_SOCKET], and more so: a session the driving desktop creates + * there also gets its remote tmux.conf (`-f`) and the hook/account `-e` env, none of which the + * phone has. + */ + fun attach(nodeId: String, socket: String, phoneCreation: String? = null, inputHandshake: Boolean = false): String { + val target = target(nodeId) + val s = socket(socket) + return """ + $PRELUDE + $PHONE_GUARD + if [ -z "${'$'}NT_TMUX" ]; then echo 'nodeterm: tmux was not found on this computer.' >&2; exit 127; fi + "${'$'}NT_TMUX" -L $s has-session -t ${q("=$target")} 2>/dev/null || exit $NO_SESSION_EXIT + ${if (socket == TmuxNames.PHONE_SOCKET) "nt_phone_owned ${q(target)} || exit $NO_SESSION_EXIT" else ""} + ${phonePin(socket, phoneCreation)} + ${if (socket == TmuxNames.PHONE_SOCKET) "\"${'$'}NT_TMUX\" -L $s set-option -t ${q("=$target:")} mouse on \\; set-option -t ${q("=$target:")} status off \\; set-option -t ${q("=$target:")} destroy-unattached off || exit 1" else ""} + TERM=xterm-256color; export TERM + $LOCALE + ${if (inputHandshake) "nt_tty=\$(tty 2>/dev/null) || exit $NO_SESSION_EXIT\nprintf 'NT-INPUT-VIEW %s\\n' \"\$nt_tty\"" else ""} + exec "${'$'}NT_TMUX" -u -L $s attach-session -t ${q("=$target")} + """.trimIndent() + } + + /** Same existing phone-shell ownership checks for attachment-bound composed input. */ + internal fun composedPrelude(nodeId: String, socket: String, phoneCreation: String?): String { + val name = target(nodeId) + socket(socket) + return """ + $PRELUDE + $PHONE_GUARD + [ -n "${'$'}NT_TMUX" ] || exit 127 + ${if (socket == TmuxNames.PHONE_SOCKET) "nt_phone_owned ${q(name)} || exit 3" else ""} + ${phonePin(socket, phoneCreation)} + """.trimIndent() + } + + /** View only the exact host-created pane. Missing/replaced generations are never recreated. */ + internal fun attachManaged(adoption: ManagedSessionAdoption, ad: SshActions.Advertisement, attach: Boolean = true, clientTty: String? = null, profilePath: String? = null): String { + val r = adoption.receipt + require(ad.instance == r.hostInstance) + require(clientTty == null || !attach && ManagedViewHandshake.validTty(clientTty)) + val format = "#{session_name}|#{session_created}|#{pane_id}|#{pane_pid}|#{NODETERM_MANAGED_CREATION_ID}" + val tuple = "${r.session}|${r.sessionCreated}|${r.paneId}|${r.panePid}|${r.creationId}" + val parts = listOf("#{==:#{session_name},${r.session}}", "#{==:#{session_created},${r.sessionCreated}}", + "#{==:#{pane_id},${r.paneId}}", "#{==:#{pane_pid},${r.panePid}}", "#{==:#{NODETERM_MANAGED_CREATION_ID},${r.creationId}}") + val condition = parts.reduce { a, b -> "#{&&:$a,$b}" } + return """ + ${prelude(profilePath)} + [ "${'$'}NT_UD" = ${q(adoption.request.profile)} ] || exit $NO_SESSION_EXIT + [ -n "${'$'}NT_TMUX" ] || exit $NO_SESSION_EXIT + ${SshActionsScripts.adoptionGuard(adoption.request.profile, ad)} + nt_tuple=${'$'}("${'$'}NT_TMUX" -L ${q(r.socket)} list-panes -s -t ${q("=" + r.session)} -F ${q(format)} 2>/dev/null) || exit $NO_SESSION_EXIT + [ "${'$'}nt_tuple" = ${q(tuple)} ] || exit $NO_SESSION_EXIT + case "${'$'}(uname -s 2>/dev/null)" in + Linux) + nt_stat=${'$'}(cat /proc/${r.panePid}/stat 2>/dev/null) || exit $NO_SESSION_EXIT + nt_boot=${'$'}(cat /proc/sys/kernel/random/boot_id 2>/dev/null) || exit $NO_SESSION_EXIT + nt_fields=${'$'}{nt_stat##*) }; set -f; set -- ${'$'}nt_fields + [ "${'$'}#" -ge 20 ] || exit $NO_SESSION_EXIT + shift 19; nt_birth="linux:${'$'}nt_boot:${'$'}1" + ;; + Darwin) + nt_started=${'$'}(LC_ALL=C ps -o lstart= -p ${r.panePid} 2>/dev/null | awk '{${'$'}1=${'$'}1; print}') + [ -n "${'$'}nt_started" ] || exit $NO_SESSION_EXIT + nt_birth="darwin:${'$'}nt_started" + ;; + *) exit $NO_SESSION_EXIT ;; + esac + [ "${'$'}nt_birth" = ${q(r.paneBirth)} ] || exit $NO_SESSION_EXIT + nt_managed_ad || exit $NO_SESSION_EXIT + TERM=xterm-256color; export TERM + $LOCALE + ${if (attach) """ + nt_tty=${'$'}(tty 2>/dev/null) || exit $NO_SESSION_EXIT + printf 'NT-MANAGED-VIEW %s\n' "${'$'}nt_tty" + """.trimIndent() else ""} + ${if (clientTty != null) """ + nt_wait=0; nt_found=0 + while [ "${'$'}nt_wait" -lt 30 ]; do + nt_clients=${'$'}("${'$'}NT_TMUX" -L ${q(r.socket)} list-clients -t ${q("=" + r.session)} -F '#{client_tty}|#{session_name}' 2>/dev/null) || exit $NO_SESSION_EXIT + if printf '%s\n' "${'$'}nt_clients" | ${"awk -v expected=" + q(clientTty + "|" + r.session)} 'BEGIN { found=0 } ${'$'}0 == expected { found=1 } END { exit !found }'; then nt_found=1; break; fi + nt_wait=${'$'}((nt_wait + 1)); sleep 0.1 + done + [ "${'$'}nt_found" = 1 ] || exit $NO_SESSION_EXIT + """.trimIndent() else ""} + # -F has no asynchronous shell probe: the final tuple and attach share a tmux queue. + # An unknown command in the false branch fails closed, with no alternate target. + exec "${'$'}NT_TMUX" -u -L ${q(r.socket)} if-shell -F -t ${q(r.paneId)} ${q(condition)} \ + ${q(if (attach) "attach-session -t " + q("=" + r.session) else "display-message -p NT-MANAGED-VERIFIED")} 'managed-session-receipt-mismatch' + """.trimIndent() + } + + /** [attach]'s exit status when the node's session is not running. */ + const val NO_SESSION_EXIT = 3 + + /** Explicit creation of a plain phone shell; attach never creates or resumes an agent. */ + fun createTerminal(nodeId: String, cwd: String?): String { + val fingerprint = PhoneTerminals.fingerprint(nodeId, cwd) + val target = q("=" + target(nodeId)) + val paneTarget = q("=" + target(nodeId) + ":") + val bootstrap = """ + for nt_var in ${'$'}(env | sed -n 's/^\(NODETERM_[A-Za-z0-9_]*\)=.*/\1/p'); do unset "${'$'}nt_var"; done + unset CLAUDE_CONFIG_DIR CODEX_HOME + TERM=xterm-256color; export TERM + # Normalize stale non-UTF-8 overrides from a warm phone server, preserving UTF-8 ones. + case "${'$'}{LC_ALL:-}" in ''|*[Uu][Tt][Ff]-8*|*[Uu][Tt][Ff]8*) ;; *) unset LC_ALL ;; esac + case "${'$'}{LC_CTYPE:-}" in ''|*[Uu][Tt][Ff]-8*|*[Uu][Tt][Ff]8*) ;; *) unset LC_CTYPE ;; esac + $LOCALE + nt_shell=${'$'}{SHELL:-/bin/sh} + case "${'$'}nt_shell" in /*) ;; *) nt_shell=/bin/sh ;; esac + [ -x "${'$'}nt_shell" ] || nt_shell=/bin/sh + exec "${'$'}nt_shell" + """.trimIndent() + return """ + $PRELUDE + [ -n "${'$'}NT_TMUX" ] || { echo 'nodeterm: tmux was not found on this computer.'; exit 127; } + nt_cwd=${cwd?.let(::q) ?: "\"${'$'}HOME\""} + case "${'$'}nt_cwd" in /*) ;; *) echo 'nodeterm: Choose an absolute folder.'; exit 2 ;; esac + for s in ${TmuxNames.SOCKET} ${TmuxNames.REMOTE_SOCKET}; do + if "${'$'}NT_TMUX" -L "${'$'}s" has-session -t $target 2>/dev/null; then echo 'nodeterm: This terminal id is already in use.'; exit 2; fi + done + if ! "${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} has-session -t $target 2>/dev/null; then + [ -d "${'$'}nt_cwd" ] && [ -x "${'$'}nt_cwd" ] || { echo 'nodeterm: That folder no longer exists or cannot be opened.'; exit 2; } + # Marker creation is atomic with the session, including after a lost SSH reply. + TERM=xterm-256color; export TERM + $LOCALE + "${'$'}NT_TMUX" -u -L ${TmuxNames.PHONE_SOCKET} -f /dev/null start-server \ + \; set-option -g history-limit 50000 \ + \; new-session -d -s ${q(target(nodeId))} -c "${'$'}nt_cwd" \ + -e ${q(PhoneTerminals.CREATION_ENV + "=" + fingerprint)} \ + -e ${q(PhoneTerminals.ID_ENV + "=" + nodeId)} \ + -e "${PhoneTerminals.CWD_ENV}=${'$'}nt_cwd" \ + -e ${q(PhoneTerminals.REQUEST_ENV + "=" + (cwd ?: "HOME"))} ${q("/bin/sh -c " + q(bootstrap))} >/dev/null 2>&1 || { + "${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} has-session -t $target 2>/dev/null || { echo 'nodeterm: Could not create the terminal.'; exit 1; } + } + fi + nt_marker=${'$'}("${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} show-environment -t $target ${PhoneTerminals.CREATION_ENV} 2>/dev/null) || { echo 'nodeterm: This terminal id belongs to another session.'; exit 2; } + [ "${'$'}nt_marker" = ${q(PhoneTerminals.CREATION_ENV + "=" + fingerprint)} ] || { echo 'nodeterm: This terminal id belongs to another session.'; exit 2; } + $PHONE_GUARD + nt_phone_owned ${q(target(nodeId))} || { echo 'nodeterm: This terminal id belongs to another session.'; exit 2; } + nt_previous=${'$'}("${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} display-message -p -t ${q("=" + target(nodeId) + ":")} '#{${PhoneTerminals.CREATION_OPTION}}' 2>/dev/null) || exit 2 + [ -z "${'$'}nt_previous" ] || [ "${'$'}nt_previous" = '$fingerprint' ] || { echo 'nodeterm: This terminal id belongs to another session.'; exit 2; } + nt_previous_id=${'$'}("${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} display-message -p -t $paneTarget '#{${PhoneTerminals.ID_OPTION}}' 2>/dev/null) || exit 2 + [ -z "${'$'}nt_previous_id" ] || [ "${'$'}nt_previous_id" = ${q(nodeId)} ] || { echo 'nodeterm: This terminal id belongs to another session.'; exit 2; } + # A retry reconciles the existing shell even if its original directory was renamed. + nt_previous_cwd=${'$'}("${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} display-message -p -t $paneTarget '#{${PhoneTerminals.CWD_OPTION}}' 2>/dev/null) || exit 2 + if [ -n "${'$'}nt_previous_cwd" ]; then nt_cwd=${'$'}nt_previous_cwd; fi + # All writes address exactly this creation's session; no canvas or shared project file. + "${'$'}NT_TMUX" -L ${TmuxNames.PHONE_SOCKET} set-option -t $paneTarget ${PhoneTerminals.ID_OPTION} ${q(nodeId)} \ + \; set-option -t $paneTarget ${PhoneTerminals.CREATION_OPTION} '$fingerprint' \ + \; set-option -t $paneTarget ${PhoneTerminals.CWD_OPTION} "${'$'}nt_cwd" \ + \; set-option -t $paneTarget mouse on \ + \; set-option -t $paneTarget status off \ + \; set-option -t $paneTarget destroy-unattached off \ + \; set-option -w -t $paneTarget history-limit 50000 || { echo 'nodeterm: Could not prepare the terminal.'; exit 1; } + echo 'created' + """.trimIndent() + } + + fun killSession(nodeId: String, socket: String, phoneCreation: String? = null): String { + val target = target(nodeId) + return """ + $PRELUDE + $PHONE_GUARD + [ -n "${'$'}NT_TMUX" ] || exit 127 + ${if (socket == TmuxNames.PHONE_SOCKET) "nt_phone_owned ${q(target)} || exit 2" else ""} + ${phonePin(socket, phoneCreation)} + "${'$'}NT_TMUX" -L ${socket(socket)} kill-session -t ${q("=$target")} + """.trimIndent() + } + + /** + * What owns the node's pane: tmux's `#{pane_current_command}`, the fact the desktop's wake reads + * (`PtyManager.paneCommand`) before it types a resume line. Prints nothing when there is no tmux + * or no such session. + */ + fun paneCommand(nodeId: String, socket: String, phoneCreation: String? = null): String { + // A PANE target, like [sendKeys]: the exact-session form for a pane command is `=name:`. + val pane = q("=" + target(nodeId) + ":") + return """ + $PRELUDE + $PHONE_GUARD + [ -n "${'$'}NT_TMUX" ] || exit 127 + ${if (socket == TmuxNames.PHONE_SOCKET) "nt_phone_owned ${q(target(nodeId))} || exit 2" else ""} + ${phonePin(socket, phoneCreation)} + "${'$'}NT_TMUX" -L ${socket(socket)} display-message -p -t $pane '#{pane_current_command}' + """.trimIndent() + } + + /** Search on the computer, never transfer its full history to the phone. No copy-mode change. */ + fun searchHistory(nodeId: String, query: String, socket: String, phoneCreation: String? = null): String { + require(TerminalHistory.validQuery(query)) { "invalid history query" } + val target = q("=" + target(nodeId) + ":") + val s = socket(socket) + return """ + $PRELUDE + $PHONE_GUARD + [ -n "${'$'}NT_TMUX" ] || exit 127 + ${if (socket == TmuxNames.PHONE_SOCKET) "nt_phone_owned ${q(target(nodeId))} || exit 2" else ""} + ${phonePin(socket, phoneCreation)} + NT_PANE=${'$'}("${'$'}NT_TMUX" -L $s display-message -p -t $target '#{pane_id}') || exit ${'$'}? + case "${'$'}NT_PANE" in %*) case "${'$'}{NT_PANE#%}" in ''|*[!0-9]*) exit 2;; esac;; *) exit 2;; esac + umask 077 + NT_HISTORY_FILE=${'$'}(mktemp "${'$'}{TMPDIR:-/tmp}/nodeterm-history.XXXXXXXX") || exit 2 + trap 'rm -f "${'$'}NT_HISTORY_FILE"' EXIT HUP INT TERM + # Bound the spool even before wc: POSIX shells use 512- or 1024-byte file blocks. + (ulimit -f 102400; "${'$'}NT_TMUX" -L $s capture-pane -p -J -t "${'$'}NT_PANE" -S - > "${'$'}NT_HISTORY_FILE") || exit 3 + NT_HISTORY_SIZE=${'$'}(wc -c < "${'$'}NT_HISTORY_FILE") || exit 3 + [ "${'$'}NT_HISTORY_SIZE" -le ${TerminalHistory.CAPTURE_BYTES} ] || exit 4 + LC_ALL=C NT_HISTORY_QUERY=${q(query)} awk ' + BEGIN { query=ENVIRON["NT_HISTORY_QUERY"]; count=0; bytes=0; truncated=0; output="" } + { sub(/\r${'$'}/, ""); if (index(${'$'}0, query)) { + value=(NR-1) "\t" ${'$'}0 "\n"; + if (count < ${TerminalHistory.MAX_ROWS} && bytes+length(value) <= ${TerminalHistory.MAX_BYTES}) { + output=output value; bytes+=length(value); count++ + } else truncated=1 + } + } + END { printf "NT-HISTORY-1\t%d\t%d\n%s", NR, truncated, output } + ' "${'$'}NT_HISTORY_FILE" + """.trimIndent() + } + + /** + * Type into a pane. `-l --` so text is literal and a leading `-` is never an option (the + * leading-dash hazard documented in tmux-naming.ts). A lone ESC is sent as the `Escape` key. + * Resolve the exact session's pane once and leave copy mode before typing: tmux otherwise + * accepts the keys into copy mode and exits successfully without delivering the answer. + */ + fun sendKeys(nodeId: String, keys: String, socket: String, phoneCreation: String? = null): String { + // A PANE target: `=name` alone is refused ("can't find pane", measured on tmux 3.4); the + // exact-session form for a pane command is `=name:` — the session's current window/pane. + val pane = q("=" + target(nodeId) + ":") + val s = socket(socket) + val send = when (keys) { + "\u001b" -> "\"${'$'}NT_TMUX\" -L $s send-keys -t \"${'$'}NT_PANE\" Escape" + "\r" -> "\"${'$'}NT_TMUX\" -L $s send-keys -t \"${'$'}NT_PANE\" Enter" + else -> "\"${'$'}NT_TMUX\" -L $s send-keys -t \"${'$'}NT_PANE\" -l -- ${q(keys)}" + } + return """ + $PRELUDE + $PHONE_GUARD + [ -n "${'$'}NT_TMUX" ] || exit 127 + ${if (socket == TmuxNames.PHONE_SOCKET) "nt_phone_owned ${q(target(nodeId))} || exit 2" else ""} + ${phonePin(socket, phoneCreation)} + NT_STATE=${'$'}("${'$'}NT_TMUX" -L $s display-message -p -t $pane '#{pane_id} #{pane_in_mode}') || exit ${'$'}? + NT_PANE=${'$'}{NT_STATE% *} + case "${'$'}NT_PANE" in %*) ;; *) exit 1 ;; esac + case "${'$'}{NT_PANE#%}" in ''|*[!0-9]*) exit 1 ;; esac + case "${'$'}{NT_STATE##* }" in + 0) ;; + 1) "${'$'}NT_TMUX" -L $s send-keys -t "${'$'}NT_PANE" -X cancel || exit ${'$'}? ;; + *) exit 1 ;; + esac + $send + """.trimIndent() + } + + /** + * Answer a held PermissionRequest hook (docs/hook-reply-approvals.md): write `allow`/`deny` to + * `~/.nodeterm/pending/.answer` atomically (temp + mv). Prints `gone` when the request file + * is no longer there — the hook already timed out or someone else answered. + */ + fun answerApproval(pendingId: String, allow: Boolean): String { + require(PENDING_ID.matches(pendingId)) { "unsafe pending id" } + val decision = if (allow) "allow" else "deny" + return """ + d="${'$'}HOME/.nodeterm/pending" + if [ ! -f "${'$'}d/$pendingId.json" ]; then echo gone; exit 0; fi + umask 077 + printf '%s' '$decision' > "${'$'}d/$pendingId.answer.tmp.${'$'}${'$'}" && mv -f "${'$'}d/$pendingId.answer.tmp.${'$'}${'$'}" "${'$'}d/$pendingId.answer" && echo sent + """.trimIndent() + } + + /** Bounded exact request + checksum. Never read another node's ticket by a path alias. */ + fun readHookRequest(nodeId: String, pendingId: String): String { + require(dev.nodeterm.protocol.model.HookReplies.belongsToNode(nodeId, pendingId)) + return """ + r="${'$'}HOME/.nodeterm/pending/$pendingId.json" + [ -f "${'$'}r" ] || { echo gone; exit 0; } + [ ! -L "${'$'}r" ] || exit 2 + [ "${'$'}(head -c ${dev.nodeterm.protocol.model.HookReplies.MAX_BYTES + 1} "${'$'}r" | wc -c)" -le ${dev.nodeterm.protocol.model.HookReplies.MAX_BYTES} ] || exit 2 + c=${'$'}(cksum < "${'$'}r") || exit 2 + [ "${'$'}{c#* }" -le ${dev.nodeterm.protocol.model.HookReplies.MAX_BYTES} ] || exit 2 + body=${'$'}(head -c ${dev.nodeterm.protocol.model.HookReplies.MAX_BYTES + 1} "${'$'}r") || exit 2 + [ "${'$'}c" = "${'$'}(cksum < "${'$'}r")" ] || exit 2 + printf '%s\n%s' "${'$'}c" "${'$'}body" + """.trimIndent() + } + + fun answerHook(nodeId: String, pendingId: String, checksum: String): String { + require(dev.nodeterm.protocol.model.HookReplies.belongsToNode(nodeId, pendingId)) + require(Regex("^[0-9]+ [0-9]+$").matches(checksum)) + return """ + d="${'$'}HOME/.nodeterm/pending" + r="${'$'}d/$pendingId.json" + [ -f "${'$'}r" ] || { echo gone; exit 0; } + [ ! -L "${'$'}r" ] || exit 2 + [ "${'$'}(cksum < "${'$'}r")" = ${q(checksum)} ] || exit 2 + umask 077 + t="${'$'}d/$pendingId.answer.tmp.${'$'}${'$'}" + trap 'rm -f "${'$'}t"' 0 HUP INT TERM + cat > "${'$'}t" || exit 2 + [ -f "${'$'}r" ] || { echo gone; exit 0; } + [ ! -L "${'$'}r" ] || exit 2 + [ "${'$'}(cksum < "${'$'}r")" = ${q(checksum)} ] || exit 2 + mv -f "${'$'}t" "${'$'}d/$pendingId.answer" && echo sent + """.trimIndent() + } + + /** The read-ack the host's ack sweep consumes (src/core/ack-sweep.ts): content = event id, + * atomic, umask 077. The node id becomes a file name, so it is held to the tmux-name alphabet. */ + fun ackRead(nodeId: String, eventId: String): String { + require(Regex("^[A-Za-z0-9_-]{1,200}$").matches(nodeId)) { "unsafe node id" } + return """ + d="${'$'}HOME/.nodeterm/acks" + umask 077 + mkdir -p "${'$'}d" && printf '%s' ${q(eventId)} > "${'$'}d/$nodeId.seen.tmp.${'$'}${'$'}" && mv -f "${'$'}d/$nodeId.seen.tmp.${'$'}${'$'}" "${'$'}d/$nodeId.seen" + """.trimIndent() + } + + /** `~/.nodeterm/relay.json` (relay-advertise.ts) for late relay adoption. */ + fun readRelayAdvertisement(): String = "cat \"\$HOME/.nodeterm/relay.json\" 2>/dev/null; exit 0" + + val PENDING_ID = Regex("^[A-Za-z0-9_-]{1,160}$") + + /** A socket name is spliced into `tmux -L`, so accept only the app's known sockets. */ + private fun socket(name: String): String { + require(name in TmuxNames.SOCKETS) { "unknown tmux socket" } + return name + } + + private fun phonePin(socket: String, creation: String?): String = + if (socket == TmuxNames.PHONE_SOCKET) "[ \"${'$'}nt_creation\" = ${q(creation ?: "")} ] || exit 2" else "" + + private fun target(nodeId: String): String { + val t = TmuxNames.sessionName(nodeId) + require(TmuxNames.isSessionName(t)) { "unsafe session target" } + return t + } +} diff --git a/android/protocol/src/test/interop/ack-fixture-runner.ts b/android/protocol/src/test/interop/ack-fixture-runner.ts new file mode 100644 index 0000000000..9bedd0d362 --- /dev/null +++ b/android/protocol/src/test/interop/ack-fixture-runner.ts @@ -0,0 +1,6 @@ +import { runAckSweep } from './ack-fixture' + +void runAckSweep().catch((err) => { + process.stdout.write(JSON.stringify({ event: 'fatal', message: String((err as Error)?.stack ?? err) }) + '\n') + process.exitCode = 1 +}) diff --git a/android/protocol/src/test/interop/ack-fixture.ts b/android/protocol/src/test/interop/ack-fixture.ts new file mode 100644 index 0000000000..0dc741cff7 --- /dev/null +++ b/android/protocol/src/test/interop/ack-fixture.ts @@ -0,0 +1,36 @@ +// The Android SSH producer's files against the desktop's actual local and remote ack consumers. +// Kept independent of the socket/crypto fixture so this shared filesystem contract needs only +// node + esbuild. HOME/USERPROFILE must name the caller's scratch directory before any file read. +import os from 'os' +import { + createAckSweeper, REMOTE_ACK_SWEEP_CMD, remoteAckSweepInput +} from '../../../../../src/core/ack-sweep' + +const emit = (obj: unknown): void => { + process.stdout.write(JSON.stringify(obj) + '\n') +} + +// ---- mode "ack-sweep" ------------------------------------------------------------------------- +export async function runAckSweep(): Promise { + if (!process.env.FIXTURE_HOME || os.homedir() !== process.env.FIXTURE_HOME) { + throw new Error('ack-sweep mode needs os.homedir() to be FIXTURE_HOME') + } + const ids = (name: string): string[] => (process.env[name] ?? '').split(',').filter(Boolean) + const owned = new Set(ids('FIXTURE_ACK_INITIAL')) + const acked: string[] = [] + const cleared: string[] = [] + const sweeper = createAckSweeper({ + handlers: { + ownsNode: (id) => owned.has(id), + ackDone: (id) => acked.push(id), + onUnreadClear: (id) => cleared.push(id) + } + }) + const consumed = sweeper.sweep() + for (const id of ids('FIXTURE_ACK_LATER')) owned.add(id) + const consumedLater = sweeper.sweep() + emit({ + ready: true, consumed, consumedLater, acked, cleared, + remoteCommand: REMOTE_ACK_SWEEP_CMD, remoteInput: remoteAckSweepInput(ids('FIXTURE_ACK_REMOTE')) + }) +} diff --git a/android/protocol/src/test/interop/bundle-fixture.cjs b/android/protocol/src/test/interop/bundle-fixture.cjs new file mode 100644 index 0000000000..1efe885420 --- /dev/null +++ b/android/protocol/src/test/interop/bundle-fixture.cjs @@ -0,0 +1,113 @@ +// Bundles host-fixture.ts for InteropHarness.kt through esbuild's JS API, so the harness only ever +// spawns `node` (audit A70). +// +// Usage (cwd = the repo root): node bundle-fixture.cjs [entry] +// +// Why not the `esbuild` command: `node_modules/.bin/esbuild` is an npm shim, and on Windows the +// extensionless one is a sh script beside esbuild.cmd. CreateProcess (what Java's ProcessBuilder +// uses) runs neither, so bundling threw an IOException and every interop test errored on Windows +// instead of running. Why not `node node_modules/esbuild/bin/esbuild` either: esbuild's install +// script replaces that JS file with the native executable on Linux and macOS (skipped only under +// `--ignore-scripts` or yarn), and node cannot run a native executable. The JS API resolves the +// platform's esbuild binary itself on every OS. +// +// The options are the ones the harness used to pass on the command line, one for one: `electron` +// aliased to the stub (audit A60), `ws` external (it resolves from the repo's node_modules at run +// time), and the repo's two path aliases. Relative alias targets resolve against the cwd, as they did +// for the CLI. +// +// It also writes esbuild's metafile beside the bundle, as `.meta.json`: the repo files the +// bundle was built from. WorkflowPathFilterTest checks that the Android workflow's path filters +// cover every one of them (audit A63). +'use strict' +const fs = require('fs') +const path = require('path') + +const [outfile, electronStub, entry = 'android/protocol/src/test/interop/host-fixture.ts'] = process.argv.slice(2) +if (!outfile || !electronStub) { + console.error('usage: node bundle-fixture.cjs [entry]') + process.exit(2) +} + +const root = process.cwd() +// Resolved from the repo root, the tree whose node_modules the harness checked for esbuild. +const esbuild = require(require.resolve('esbuild', { paths: [root] })) +// Only the managed-launch fixture runs the real PtyManager. Replace its native process boundary +// and login-PATH probe; virtualize os.homedir() so node tokens/accounts cannot touch a real profile. +// Settings/trust/workspace/host handlers are the production implementations, never aliases. +const projectLaunch = entry === 'android/protocol/src/test/interop/project-launch-fixture.ts' +const managedSession = entry === 'android/protocol/src/test/interop/managed-session-fixture.ts' +const serverProfile = entry === 'android/protocol/src/test/interop/server-profile-fixture.ts' +const serverProfilePlugin = { + name: 'isolated-server-profile-home', + setup(build) { + build.onResolve({ filter: /^(?:os|node:os)$/ }, (args) => { + if (args.importer.endsWith('/server-profile-os.ts')) return + return { path: path.join(root, 'android/protocol/src/test/interop/server-profile-os.ts') } + }) + } +} +const launchSeams = { + 'os': 'launch-os.ts', 'node:os': 'launch-os.ts', + 'node-pty': 'launch-native.ts' +} +const launchPlugin = { + name: 'isolated-managed-launch', + setup(build) { + build.onResolve({ filter: /^(?:os|node:os|node-pty|child_process|\.\/(?:exec-path|tmux-hint))$/ }, (args) => { + if (args.importer.endsWith('/launch-os.ts')) return + const managerPath = args.importer === path.join(root, 'src/core/pty-manager.ts') || + args.importer === path.join(root, 'src/core/tmux-hint.ts') + const managerBoundary = managerPath && ['./exec-path', './tmux-hint', 'child_process'].includes(args.path) + const nativeLeaf = managedSession ? 'managed-native.ts' : 'launch-native.ts' + const leaf = args.path === 'node-pty' ? nativeLeaf : + launchSeams[args.path] || (managerBoundary ? nativeLeaf : null) + if (leaf) return { path: path.join(root, 'android/protocol/src/test/interop', leaf) } + }) + } +} + +esbuild + .build({ + absWorkingDir: root, + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'cjs', + // A worktree may share cached dependencies through a node_modules symlink. Keep those + // dependency inputs under the logical node_modules path for the repository-file scanner. + preserveSymlinks: true, + outfile: path.resolve(root, outfile), + alias: { + electron: './' + electronStub, + '@shared': './src/shared', + '@renderer': './src/renderer' + }, + // Keep the genuine SSH package at runtime: its optional native acceleration modules are + // allowed to be absent under the protocol fixture's npm ci --ignore-scripts contract. + external: ['ws', 'esbuild', 'ssh2'], + plugins: projectLaunch || managedSession ? [launchPlugin] : serverProfile ? [serverProfilePlugin] : [], + metafile: true, + logLevel: 'warning' + }) + .then(async (result) => { + if (entry === 'android/protocol/src/test/interop/host-fixture.ts') { + // Actual standalone host producer, with only node-pty's native leaf recorded explicitly. + const nativeHost = await esbuild.build({ absWorkingDir: root, + entryPoints: ['src/session-host/host.ts'], bundle: true, platform: 'node', format: 'cjs', + outfile: path.resolve(root, outfile) + '-composed-host.cjs', preserveSymlinks: true, metafile: true, logLevel: 'warning', + plugins: [{ name: 'explicit-composed-native-recorder', setup(build) { + build.onResolve({ filter: /^node-pty$/ }, () => ({ + path: path.join(root, 'src/session-host/__fixtures__/composed-native-recorder.ts') })) + } }] }) + Object.assign(result.metafile.inputs, nativeHost.metafile.inputs) + } + fs.writeFileSync(path.resolve(root, outfile) + '.meta.json', JSON.stringify(result.metafile)) + }) + .catch((err) => { + // A build failure: esbuild has already printed the errors (logLevel 'warning' includes them) to + // stderr, which the harness folds into its failure message. Anything else (the metafile write) + // has printed nothing yet. + if (!err || !Array.isArray(err.errors)) console.error(err) + process.exitCode = 1 + }) diff --git a/android/protocol/src/test/interop/composed-backend-fixture.ts b/android/protocol/src/test/interop/composed-backend-fixture.ts new file mode 100644 index 0000000000..f3c10f2a98 --- /dev/null +++ b/android/protocol/src/test/interop/composed-backend-fixture.ts @@ -0,0 +1,100 @@ +// Real backend/emulator/client code; only the native process is an explicit byte recorder. +// This does not claim Windows ConPTY or kernel PTY acceptance (the native proof is separate). +import fs from 'node:fs' +import { performance } from 'node:perf_hooks' +import { NativeWindowsPane } from '../../../../../src/core/native-windows-pane' +import { SessionHostClient } from '../../../../../src/core/session-host-client' +import { SessionHostPty } from '../../../../../src/core/session-host-pty' +import { bootComposedHost } from '../../../../../src/session-host/__fixtures__/composed-host' +import type { NativeScrollResult } from '../../../../../src/shared/history-scroll' +import type { ComposedInput, ComposedInputResult } from '../../../../../src/shared/composed-input' + +export async function composedBackendFixture(root: string, kind: string, emit: (event: object) => void) { + const mode = process.env.FIXTURE_COMPOSED_MODE ?? 'on' + const historyMode = process.env.FIXTURE_HISTORY_SCROLL + const historyMouse = historyMode === 'default' ? '\x1b[?1000h\x1b[?1006l' : + historyMode === 'sgr' ? '\x1b[?1000h\x1b[?1006h' : + historyMode === 'pixels' ? '\x1b[?1000h\x1b[?1016h' : '\x1b[?1000l\x1b[?1006l' + if (historyMode && !['off', 'default', 'sgr', 'pixels'].includes(historyMode)) throw new Error('Invalid history fixture mode') + const historyOutput = Array.from({ length: 200 }, (_, i) => `H${String(i).padStart(4, '0')} pre-attach Ω retained row`).join('\r\n') + + '\r\n' + 'wrapped-'.repeat(12) + '\r\n\x1b[?1049hcurrent alternate fixture ready\r\n' + historyMouse + const emitWrites = (writes: Array<{ data: string; at: number }>): void => { + for (const write of writes) emit({ event: 'composed-native-write', backend: kind, ...write }) + } + let submit: (input: ComposedInput, current: () => boolean) => Promise + let control: (command: string) => Promise + let close: () => void + let scroll: (up: boolean, lines: number, capture: boolean, current: () => boolean) => Promise + if (kind === 'native-windows') { + const writes: Array<{ data: string; at: number }> = [] + const pane = new NativeWindowsPane({ pid: 123, write: data => writes.push({ data, at: performance.now() }) }, + { cols: 80, rows: 24, scrollback: historyMode ? 400 : 100 }) + if (historyMode) pane.recordOutput(historyOutput) + pane.recordOutput('\x1b[?20'); pane.recordOutput(mode === 'off' ? '04lfixture ready\r\n' : '04hfixture ready\r\n') + submit = async (input, current) => { const start = writes.length + const result = await pane.submitComposed(input, current); emitWrites(writes.slice(start)); return result } + scroll = async (up, lines, capture, current) => { const start = writes.length + const result = await pane.scrollForHistory(up, lines, capture, current) + const delta = writes.slice(start); emitWrites(delta) + emit({ event: 'history-native-result', backend: kind, status: result.status, writes: delta.map(write => write.data) }) + return result } + control = async (command) => { + const modes: Record = { + 'history-mouse-default': '\x1b[?1000h\x1b[?1006l\x1b[?1016l', + 'history-mouse-sgr': '\x1b[?1000h\x1b[?1006h', + 'history-mouse-pixels': '\x1b[?1000h\x1b[?1016h', + 'history-mouse-off': '\x1b[?1000l\x1b[?1002l\x1b[?1003l', + 'history-more-output': '\x1b[?1049l' + Array.from({ length: 10_000 }, (_, i) => `new live row ${i}`).join('\r\n') + } + if (!Object.hasOwn(modes, command)) throw new Error('Unknown native history fixture control') + pane.recordOutput(modes[command]); await pane.capture(true) + } + close = () => pane.dispose() + } else if (kind === 'session-host') { + fs.mkdirSync(root, { recursive: true }) + const host = await bootComposedHost(root, process.argv[1] + '-composed-host.cjs', mode) + let ownedPainter: SessionHostPty | undefined + const stop = async (): Promise => { ownedPainter?.destroy(); await host.close() } + const terminate = (): void => { void stop().then(() => process.exit(0), () => process.exit(1)) } + process.once('SIGTERM', terminate) + try { + const client = new SessionHostClient({ userDataDir: host.dataDir }) + if (historyMode) host.spawnOptions.env.NT_HISTORY_SCROLL_OUTPUT = historyOutput + const painter = new SessionHostPty(client, 'android-owned', host.spawnOptions, historyMode ? 400 : 100) + ownedPainter = painter + let output = '' + let modeTimer: ReturnType | undefined + const modeObserved = new Promise((resolve, reject) => { + modeTimer = setTimeout(() => reject(new Error('Native history fixture output deadline')), 4000) + painter.onData(data => { output += data; if (output.includes('fixture ready')) resolve() }) + }) + try { await Promise.all([painter.ready, modeObserved]) } finally { if (modeTimer) clearTimeout(modeTimer) } + submit = async (input, current) => { const start = host.writes().length + const result = await painter.submitComposed(input, current); emitWrites(host.writes().slice(start)); return result } + scroll = async (up, lines, capture, current) => { const start = host.writes().length + const result = await painter.scrollForHistory(up, lines, capture, current) + const delta = host.writes().slice(start); emitWrites(delta) + emit({ event: 'history-native-result', backend: kind, status: result.status, writes: delta.map(write => write.data) }) + return result } + control = async (command) => { + if (!['history-mouse-default', 'history-mouse-sgr', 'history-mouse-pixels', 'history-mouse-off', 'history-more-output'].includes(command)) + throw new Error('Unknown session-host history fixture control') + let waiting = true + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { waiting = false; reject(new Error('History fixture control deadline')) }, 4000) + painter.onData(data => { if (waiting && data.includes('history-control-ready:' + command)) { + waiting = false; clearTimeout(timer); resolve() + } }) + painter.write(command) + }) + } + close = () => { void stop() } + } catch (error) { + // Startup failure must reap this child before the parent exits and loses descendant proof. + try { await stop() } finally { process.removeListener('SIGTERM', terminate) } + throw error + } + } else throw new Error('Unknown explicit composed backend fixture') + emit({ event: 'composed-backend-ready', backend: kind, nativeBoundary: 'byte-recorder', actualBackend: true }) + return { submit, scroll, control, close } +} diff --git a/android/protocol/src/test/interop/electron-stub.ts b/android/protocol/src/test/interop/electron-stub.ts new file mode 100644 index 0000000000..643d9edcad --- /dev/null +++ b/android/protocol/src/test/interop/electron-stub.ts @@ -0,0 +1,46 @@ +// Stand-in for the `electron` package in the Android interop fixture. InteropHarness.kt bundles +// host-fixture.ts with `--alias:electron=`, so the bundle never `require`s the real one. +// +// Why (audit A60): host-service.ts, host-identity.ts and host-canvas-hub.ts import `electron` at the +// top. Outside Electron, `require('electron')` runs the npm package's index.js, which returns the PATH +// of the Electron binary and, when that binary is missing, first downloads it synchronously: about +// 110 MB from GitHub releases, inside the harness's 20 s wait for the fixture's ready line. The package +// has no install script, so every fresh `npm ci` (a CI runner) lacks the binary; a slow or failed +// download then failed every interop test with a timeout. +// +// The fixture injects everything those modules would otherwise take from Electron (the pty, the +// canvas feed, the host keys, the relay gate), so no member below is reached at run time. Under the +// real package every member was `undefined` here, so any use already failed; each member keeps that, +// but throws an error naming the member instead of a bare TypeError on `undefined`. A no-op would +// instead let a code path that started depending on Electron pass silently (`app.isPackaged` would +// read as a plain falsy value, `ipcMain.on` would register nothing). +// +// Export every member that a src/main module the fixture reaches imports from 'electron'; between +// them the src/main/remote files import these four (plus types, which esbuild erases). A missing one +// fails the esbuild step with "No matching export", which is the intended signal: add it here, do not +// reach for the real package. + +function unavailable(member: string): never { + throw new Error( + `electron.${member} is not available in the Android interop fixture ` + + '(android/protocol/src/test/interop/electron-stub.ts): inject the dependency into the desktop code instead' + ) +} + +function stub(name: string): Record { + return new Proxy(Object.create(null) as Record, { + get(_target, prop) { + // Symbol keys (util.inspect, Symbol.toPrimitive) and `then` (a stray `await`) are probes, not uses. + if (typeof prop === 'symbol' || prop === 'then') return undefined + return unavailable(`${name}.${prop}`) + }, + set(_target, prop) { + return unavailable(`${name}.${String(prop)}`) + } + }) +} + +export const app = stub('app') +export const ipcMain = stub('ipcMain') +export const safeStorage = stub('safeStorage') +export const dialog = stub('dialog') diff --git a/android/protocol/src/test/interop/fixtures/terminal-closed-view-raw.json b/android/protocol/src/test/interop/fixtures/terminal-closed-view-raw.json new file mode 100644 index 0000000000..7e8b092a1a --- /dev/null +++ b/android/protocol/src/test/interop/fixtures/terminal-closed-view-raw.json @@ -0,0 +1,34 @@ +{ + "schema": 1, + "provenance": { + "sourceRevision": "656e5d8a8a05985736f6ca715b0f9485b107391e", + "nativeRunnerSha256": "25b982523b1206c5b72a8803e1cf175d5e4354614c0b5e0e1ab2c6ec79f26451", + "tmuxSha256": "be18b355c0881bd3f9d94ea9e4ff335f0c0cd60910f2e8ecfae7468995a45b21", + "producerFinalWriteBytes": 17, + "scope": "Owned Linux tmux client PTY recordings; fixed glyphs and control sequences only. Replay tests parser and same-page display, not original SSH/phone causation or transient paint timing." + }, + "records": [ + { + "variant": "immediate", + "rawSha256": "b84c4363df8ad30e0d409866ed9b03ada01464c1b2dc58e2aa3685594c31eeb7", + "beforeOffset": 1387, + "cols": 49, + "rows": 48, + "requestedFinalWriteDelayMs": 0, + "resultSha256": "7574288b96f4aa108e79a9aa1a4e863fd7f7fa7f133d24ca5b26c47ef6e28479", + "bytes": 1542, + "base64": "G1s/MTA0OWgbWzIyOzA7MHQbWz8xaBs9G1tIG1syShtbPzEybBtbPzI1aBtbPzEwMDBsG1s/MTAwMmwbWz8xMDAzbBtbPzEwMDZsG1s/MTAwNWwbWz8yMDA0aBtbPzIwMzFoG1s/OTk2bhsoQhtbbRtbPzEybBtbPzI1aBtbPzEwMDZsG1s/MTAwMGwbWz8xMDAybBtbPzEwMDNsG1sxOzFIG1sxOzQ4chtbYxtbPmMbWz5xG10xMDs/G1wbXTExOz8bXBtbMTQ7MUgbWz8xMDA2bBtbPzEwMDBsG1s/MTAwMmwbWz8xMDAzbBtbPzEwMDZoG1s/MTAwMGgbWz8xMDAyaBtbPzI1bBtbSEExMzNfUk9XXzAwIM6p8J+nrRtbSw0KQTEzM19ST1dfMDEgzqnwn6etG1tLDQpBMTMzX1JPV18wMiDOqfCfp60bW0sNCkExMzNfUk9XXzAzIM6p8J+nrRtbSw0KQTEzM19ST1dfMDQgzqnwn6etG1tLDQpBMTMzX1JPV18wNSDOqfCfp60bW0sNCkExMzNfUk9XXzA2IM6p8J+nrRtbSw0KQTEzM19ST1dfMDcgzqnwn6etG1tLDQpBMTMzX1JPV18wOCDOqfCfp60bW0sNCkExMzNfUk9XXzA5IM6p8J+nrRtbSw0KQTEzM19ST1dfMTAgzqnwn6etG1tLDQpBMTMzX1JPV18xMSDOqfCfp60bW0sNCkExMzNfQkVGT1JFX0VYSVQbW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLG1s/MTJsG1s/MjVoG1sxNGQbWzE4dBtbMTR0GyhCG1ttG1s/MTJsG1s/MjVoG1s/MTAwNmwbWz8xMDAwbBtbPzEwMDJsG1s/MTAwM2wbWzE7MUgbWzE7NDhyG1sxNDsxSBtbPzEwMDZsG1s/MTAwMGwbWz8xMDAybBtbPzEwMDNsG1s/MTAwNmgbWz8xMDAwaBtbPzEwMDJoG1s/MjVsG1tIQTEzM19ST1dfMDAgzqnwn6etG1tLDQpBMTMzX1JPV18wMSDOqfCfp60bW0sNCkExMzNfUk9XXzAyIM6p8J+nrRtbSw0KQTEzM19ST1dfMDMgzqnwn6etG1tLDQpBMTMzX1JPV18wNCDOqfCfp60bW0sNCkExMzNfUk9XXzA1IM6p8J+nrRtbSw0KQTEzM19ST1dfMDYgzqnwn6etG1tLDQpBMTMzX1JPV18wNyDOqfCfp60bW0sNCkExMzNfUk9XXzA4IM6p8J+nrRtbSw0KQTEzM19ST1dfMDkgzqnwn6etG1tLDQpBMTMzX1JPV18xMCDOqfCfp60bW0sNCkExMzNfUk9XXzExIM6p8J+nrRtbSw0KQTEzM19CRUZPUkVfRVhJVBtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sbWz8xMmwbWz8yNWgbWzE0ZEExMzNfRklOQUxfVEFJTA0KG1sxOzQ4chsoQhtbbRtbPzFsGz4bW0gbWzJKG1s/MTJsG1s/MjVoG1s/MTAwMGwbWz8xMDAybBtbPzEwMDNsG1s/MTAwNmwbWz8xMDA1bBtbPzIwMDRsG1s/NzcyN2wbWz8xMDA0bBtbPzEwNDlsG1syMzswOzB0G1s/MjAzMWxbZXhpdGVkXQ0K" + }, + { + "variant": "delayed", + "rawSha256": "1e5a93d7e6bac2546005f9ecd937584662e4d85d75de9d6e02aa1606ee48aff4", + "beforeOffset": 1383, + "cols": 49, + "rows": 48, + "requestedFinalWriteDelayMs": 150, + "resultSha256": "95700b01f69fcc99c5e65cc9d142e3234631f93b85cbb3b1228db1cfbc18fa9e", + "bytes": 1538, + "base64": "G1s/MTA0OWgbWzIyOzA7MHQbWz8xaBs9G1tIG1syShtbPzEybBtbPzI1aBtbPzEwMDBsG1s/MTAwMmwbWz8xMDAzbBtbPzEwMDZsG1s/MTAwNWwbWz8yMDA0aBtbPzIwMzFoG1s/OTk2bhsoQhtbbRtbPzEybBtbPzI1aBtbPzEwMDZsG1s/MTAwMGwbWz8xMDAybBtbPzEwMDNsG1sxOzFIG1sxOzQ4chtbYxtbPmMbWz5xG10xMDs/G1wbXTExOz8bXBtbMTQ7MUgbWz8xMDA2bBtbPzEwMDBsG1s/MTAwMmwbWz8xMDAzbBtbPzEwMDZoG1s/MTAwMGgbWz8xMDAyaBtbPzI1bBtbSEExMzNfUk9XXzAwIM6p8J+nrRtbSw0KQTEzM19ST1dfMDEgzqnwn6etG1tLDQpBMTMzX1JPV18wMiDOqfCfp60bW0sNCkExMzNfUk9XXzAzIM6p8J+nrRtbSw0KQTEzM19ST1dfMDQgzqnwn6etG1tLDQpBMTMzX1JPV18wNSDOqfCfp60bW0sNCkExMzNfUk9XXzA2IM6p8J+nrRtbSw0KQTEzM19ST1dfMDcgzqnwn6etG1tLDQpBMTMzX1JPV18wOCDOqfCfp60bW0sNCkExMzNfUk9XXzA5IM6p8J+nrRtbSw0KQTEzM19ST1dfMTAgzqnwn6etG1tLDQpBMTMzX1JPV18xMSDOqfCfp60bW0sNCkExMzNfQkVGT1JFX0VYSVQbW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLG1s/MTJsG1s/MjVoG1sxNGQbWzE4dBtbMTR0GyhCG1ttG1s/MTJsG1s/MjVoG1s/MTAwNmwbWz8xMDAwbBtbPzEwMDJsG1s/MTAwM2wbWzE7MUgbWzE7NDhyG1s/MjVsG1sxOzFIQTEzM19ST1dfMDAgzqnwn6etG1tLDQpBMTMzX1JPV18wMSDOqfCfp60bW0sNCkExMzNfUk9XXzAyIM6p8J+nrRtbSw0KQTEzM19ST1dfMDMgzqnwn6etG1tLDQpBMTMzX1JPV18wNCDOqfCfp60bW0sNCkExMzNfUk9XXzA1IM6p8J+nrRtbSw0KQTEzM19ST1dfMDYgzqnwn6etG1tLDQpBMTMzX1JPV18wNyDOqfCfp60bW0sNCkExMzNfUk9XXzA4IM6p8J+nrRtbSw0KQTEzM19ST1dfMDkgzqnwn6etG1tLDQpBMTMzX1JPV18xMCDOqfCfp60bW0sNCkExMzNfUk9XXzExIM6p8J+nrRtbSw0KQTEzM19CRUZPUkVfRVhJVBtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sNChtbSw0KG1tLDQobW0sbWz8xMmwbWz8yNWgbWzE0ZBtbPzEwMDZsG1s/MTAwMGwbWz8xMDAybBtbPzEwMDNsG1s/MTAwNmgbWz8xMDAwaBtbPzEwMDJoQTEzM19GSU5BTF9UQUlMDQobWzE7NDhyGyhCG1ttG1s/MWwbPhtbSBtbMkobWz8xMmwbWz8yNWgbWz8xMDAwbBtbPzEwMDJsG1s/MTAwM2wbWz8xMDA2bBtbPzEwMDVsG1s/MjAwNGwbWz83NzI3bBtbPzEwMDRsG1s/MTA0OWwbWzIzOzA7MHQbWz8yMDMxbFtleGl0ZWRdDQo=" + } + ] +} diff --git a/android/protocol/src/test/interop/host-fixture.ts b/android/protocol/src/test/interop/host-fixture.ts new file mode 100644 index 0000000000..3f3fe0bded --- /dev/null +++ b/android/protocol/src/test/interop/host-fixture.ts @@ -0,0 +1,796 @@ +// Interop fixture for the Android protocol tests: runs the DESKTOP's real code on the other end of +// the wire, so the Kotlin client is checked against the implementation, not against a reading of it. +// +// mode "relay": a tiny in-process relay broker (pairs a host and a client by room, forwards +// frames preserving text/binary — all the real relay does) plus the desktop's +// `connectHostSession` (src/main/remote/host-service.ts → relay-socket.ts host role) +// serving a FAKE pty/kanban/inbox bridge that records what the phone asked for. +// `projects.list` is NOT faked: the desktop's `buildProjectsListBlob` over a real +// `WorkspaceStore` and a mirror file the real agent-status mirror wrote, all under +// FIXTURE_USERDATA (see seedDesktopState; audit A64). Neither is `git.*`: the real +// `GitService` behind the real jail, over a repository in the project's folder (see +// seedGitRepo; audit A29). Nor the `lan` field beside the blob: the desktop's own +// `createHostLanReporter` over the test's interfaces and host-key dir (A74-refresh). +// mode "pair": the desktop's real `createPairingService` (src/main/pairing-service.ts) with the +// home dir pointed at a temp dir by the caller (HOME, and USERPROFILE for Windows, see +// InteropHarness.scratchHomeEnv; refused unless `os.homedir()` is FIXTURE_HOME), and a +// fake `/v1/relay/device` API. It exercises the direct-SSH pairing path on every OS. +// The SSH host keys its sealed answer names are read from FIXTURE_SSH_HOST_KEY_DIR (a +// dir of `ssh_host_*_key.pub` the test lays out), or from a dir that does not exist +// when unset, never from the machine's real /etc (audit A49-anchor). +// With FIXTURE_LATE_PIN_KEYS it then asks the service, per key, what the standing host +// asks on a relay handshake (`approvePairedRelayKey`, audit A07-late), before and +// after revoking every device. +// mode "ack-sweep": the real shared-file consumers (also bundled independently by +// ack-fixture-runner.ts), receiving ownership and a scratch home from the caller. +// mode "launch-parity": real mirror host facts and shared desktop approval command assembly. +// mode "pairing-network": real QR builder, adapter policy and LAN reporter over an explicit +// fake OS-interface boundary. No HTTP pairing/authentication or socket is simulated. +// mode "never-ready": prints nothing and stays alive, so InteropHarnessTest can check that a +// harness whose ready wait fails still kills the process. +// +// Protocol with the Kotlin test: line 1 on stdout is a JSON "ready" object; every later line is a +// JSON event. Bundled by esbuild at test time (see InteropHarness.kt); `ws` stays external and +// resolves from the repo's node_modules, and `electron` is aliased to ./electron-stub.ts, so the +// real package (whose first `require` downloads the Electron binary) is never loaded (audit A60). +// esbuild only strips types, so this file is type-checked by `npm run typecheck` as part of +// tsconfig.node.json (audit A67): a desktop interface it implements cannot drift from it unseen. +// Do not cast what it hands to the desktop code (`as unknown as`, `as never`); a cast turns that +// check off for the value, and the drift then shows up only at run time. +import { execFileSync } from 'child_process' +import { createInterface } from 'readline' +import { runAckSweep } from './ack-fixture' +import { composedBackendFixture } from './composed-backend-fixture' +import fs from 'fs' +import http from 'http' +import os from 'os' +import path from 'path' +import { WebSocketServer, type WebSocket } from 'ws' +import { initPlatform, platform } from '../../../../../src/core/platform' +import { genKeyPair, publicKeyToB64 } from '../../../../../src/main/remote/e2ee' +import { + connectHostSession, + type HostPtyManager, + type HostSession +} from '../../../../../src/main/remote/host-service' +import { createHostNewSessions } from '../../../../../src/main/remote/host-new-sessions' +import { createHostLanReporter } from '../../../../../src/main/remote/host-lan-report' +import { + flush as flushMirror, + initAgentStatusMirror, + mirrorClaudeAccount, + recordAgentEvent, + recordRawToolEvent, + setMirrorSettingsProvider, + setNodeSessionName, + type MirrorSettings +} from '../../../../../src/core/agent-status-mirror' +import { + claudeAccountsSnapshot, + claudeConfigDirFor, + observedClaudeAccount, + registerClaudeAccountsSource +} from '../../../../../src/core/claude-config-dir' +import { GitService } from '../../../../../src/core/git-service' +import { searchTerminalHistory } from '../../../../../src/core/terminal-history' +import { buildProjectsListBlob } from '../../../../../src/core/projects-list-blob' +import { WorkspaceStore } from '../../../../../src/core/workspace-store' +import { codexApprovalValuesFrom } from '../../../../../src/shared/agents/codex-approval-values' +import { assembleLaunchCommand, assembleResumeCommand } from '../../../../../src/shared/agents/launch' +import { AGENT_CONFIG, ALL_PERMISSION_MODES, gatePermissionMode } from '../../../../../src/shared/agents/config' +import { normalizeFor } from '../../../../../src/shared/agents/normalize' +import { createPairingService } from '../../../../../src/main/pairing-service' +import { emptyApprovedDevices, isPinned, pinDevice } from '../../../../../src/main/remote/approved-devices-core' +import { createRevoker } from '../../../../../src/main/remote/revocation' +import { buildPairingPayload } from '../../../../../src/main/pairing-core' +import { pairingNetworkChoices, pairingNetworkIPv4, type PairingInterfaces } from '../../../../../src/shared/pairing-network' +import type { DetachedSinks } from '../../../../../src/core/pty-manager' +import { DEFAULT_SETTINGS, type ClaudeAccount, type Workspace } from '../../../../../src/shared/types' + +const emit = (obj: unknown): void => { + process.stdout.write(JSON.stringify(obj) + '\n') +} + +initPlatform({ + userDataDir: process.env.FIXTURE_USERDATA ?? process.cwd(), + appVersion: '0.0.0-interop', + isPackaged: false, + handle: () => {}, + on: () => {}, + handleWithSender: () => {}, + onWithSender: () => {}, + sendTo: () => {}, + broadcast: () => {}, + clientIds: () => [], + openExternal: async () => {} +}) + +// ---- a relay broker: pair `?token=host-` with `?token=client-` ------------------------ + +export function startBroker(): Promise { + const rooms = new Map() + const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 }) + wss.on('connection', (ws, req) => { + const token = new URL(req.url ?? '/', 'http://x').searchParams.get('token') ?? '' + const m = /^(host|client)-(.+)$/.exec(token) + if (!m) { + ws.close(4001, 'bad token') + return + } + const role = m[1] as 'host' | 'client' + const room = rooms.get(m[2]) ?? { queue: [] } + rooms.set(m[2], room) + room[role] = ws + emit({ event: 'broker-join', role }) + const peerOf = (r: 'host' | 'client'): 'host' | 'client' => (r === 'host' ? 'client' : 'host') + const flush = (): void => { + room.queue = room.queue.filter((q) => { + const target = room[q.to] + if (!target) return true + target.send(q.data as Buffer, { binary: q.binary }) + return false + }) + } + flush() + ws.on('message', (data, isBinary) => { + room.queue.push({ to: peerOf(role), data: isBinary ? data : data.toString('utf-8'), binary: isBinary }) + flush() + }) + ws.on('close', () => { + emit({ event: 'broker-close', role }) + room[peerOf(role)]?.close() + room[role] = undefined + }) + }) + return new Promise((resolve) => wss.on('listening', () => resolve((wss.address() as { port: number }).port))) +} + +// ---- mode "relay" ---------------------------------------------------------------------------------- + +/** + * What the phone lists (`projects.list`), produced by the desktop's own code (audit A64), under the + * caller's scratch userData (FIXTURE_USERDATA; the Kotlin test deletes it): + * - the workspace: the real `WorkspaceStore` writes it as the desktop does (a v3 index plus the + * folder's `.nodeterm/project.json`), and the blob serves the store's read-only `load()` of it; + * - `agent-status.json`: the real mirror, fed the hook POSTs a Claude session makes, as both shells + * feed it (the raw listener's `recordRawToolEvent`, then the normalized event with the hook + * server's account label into `recordAgentEvent`), and flushed by its own writer; + * - the mirror's settings block: the shells' provider body over one managed Claude account. + * Hand-written here: the workspace the renderer would hand the store, the hook payloads (with the + * `nodeterm_pending_id` the hook server merges in from its form field), the session name the + * name sweep would publish, and the tmux session list (there is no tmux). + */ +async function seedDesktopState(settings: Partial = {}): Promise { + const userData = platform().userDataDir + const workspace: Workspace = { + version: 2, + activeProjectId: 'p1', + projects: [ + { + id: 'p1', + name: 'Demo', + color: '#0a84ff', + cwd: path.join(userData, 'demo'), + viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [ + { id: 'term-abc-1', kind: 'terminal', title: 'Claude', color: '#d97757', agentId: 'claude', group: null, position: { x: 0, y: 0 }, size: { width: 1, height: 1 } }, + { id: 'note-1', kind: 'sticky', title: 'Note', color: '#fff', group: null, text: 'hi', position: { x: 0, y: 0 }, size: { width: 1, height: 1 } } + ], + kanban: { + columns: [{ id: 'c1', title: 'To Do', color: '#0a84ff' }], + assignments: [{ nodeId: 'term-abc-1', columnId: 'c1' }], + labels: [{ id: 'l1', name: 'bug', color: 'red' }], + meta: [{ nodeId: 'term-abc-1', labels: ['l1'], priority: 'high' }] + } + } + ] + } + const store = new WorkspaceStore() + await store.save(workspace) + + // A39/A75: one managed Claude account, registered the way both shells register settings. + const accounts: ClaudeAccount[] = [{ id: 'acct-1', label: 'Work', email: 'me@work.example', createdAt: 0 }] + registerClaudeAccountsSource(() => accounts) + setMirrorSettingsProvider(() => ({ + claudePermissionMode: 'manual', + autoSupported: false, + ...settings, + claudeAccounts: claudeAccountsSnapshot() + .filter((a) => !a.host && !a.pending) + .map((a) => mirrorClaudeAccount(a, claudeConfigDirFor(a.id))) + })) + + // As both shells do at boot: the mirror's default file, `/agent-status.json`, which is + // where `buildProjectsListBlob` reads it back. + initAgentStatusMirror() + const nodeId = 'term-abc-1' + const session = { + session_id: 's-1', + // Under the account's config dir, so the hook server's label names acct-1. + transcript_path: path.join(claudeConfigDirFor('acct-1'), 'projects', '-demo', 's-1.jsonl'), + cwd: workspace.projects[0].cwd + } + const hook = (payload: Record, id = nodeId): void => { + recordRawToolEvent(id, payload) + const ev = normalizeFor('claude', { nodeId: id, agentId: 'claude', payload }) + const account = observedClaudeAccount('claude', payload) + if (ev) recordAgentEvent({ ...ev, ...(account ? { account } : {}) }) + } + const bash = { tool_name: 'Bash', tool_input: { command: 'npm test' } } + hook({ ...session, hook_event_name: 'PreToolUse', ...bash }) + // A held hook-reply approval: the managed hook's deterministic ticket. + hook({ ...session, hook_event_name: 'PermissionRequest', ...bash, nodeterm_pending_id: 'term-abc-1-1700000000000-42', + ...(process.env.FIXTURE_STRUCTURED_HOOKS === '1' ? { nodeterm_hook_reply: 2, permission_suggestions: [ + { type: 'addRules', behavior: 'allow', destination: 'localSettings', rules: [{ toolName: 'Bash', ruleContent: 'npm test' }] } + ] } : {}) }) + if (process.env.FIXTURE_STRUCTURED_HOOKS === '1') hook({ ...session, hook_event_name: 'PreToolUse', tool_name: 'AskUserQuestion', + tool_use_id: 'question-tool', nodeterm_hook_reply: 2, nodeterm_pending_id: 'term-question-1-1700000000000-43', tool_input: { questions: [ + { question: 'Languages?', header: 'Languages', multiSelect: true, options: [{ label: 'Kotlin', description: 'Phone' }, { label: 'TypeScript', description: 'Host' }] }, + { question: 'Where?', header: 'Place', multiSelect: false, options: [{ label: 'Here', description: '' }, { label: 'There', description: '' }] } + ] } }, 'term-question-1') + setNodeSessionName(nodeId, 'fix bug') + await flushMirror() + return store +} + +/** + * The project's folder as a git repository the phone's source control works on (audit A29), made the + * way a user would make one: an initial commit (the project's own `.nodeterm/project.json` included) + * pushed to a bare `origin` beside the folder, then one change of each kind the status reports — a + * staged new file, a modified tracked file and an untracked file. Repo-local identity, no signing and + * no hooks, so a contributor's global git config cannot decide whether the phone's commit succeeds. + */ +function seedGitRepo(dir: string): void { + const git = (cwd: string, ...args: string[]): void => { + execFileSync('git', args, { cwd, stdio: 'pipe' }) + } + const origin = path.join(path.dirname(dir), 'origin.git') + git(path.dirname(dir), 'init', '--bare', '--initial-branch=main', origin) + git(dir, 'init', '--initial-branch=main') + git(dir, 'config', 'user.name', 'Interop') + git(dir, 'config', 'user.email', 'interop@example.test') + git(dir, 'config', 'commit.gpgsign', 'false') + git(dir, 'config', 'core.hooksPath', path.join(dir, '.git', 'no-hooks')) + fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n') + git(dir, 'add', '-A') + git(dir, 'commit', '-m', 'initial') + git(dir, 'remote', 'add', 'origin', origin) + git(dir, 'push', '-u', 'origin', 'main') + fs.writeFileSync(path.join(dir, 'README.md'), 'hello again\n') + fs.writeFileSync(path.join(dir, 'staged.txt'), 'staged\n') + git(dir, 'add', 'staged.txt') + fs.writeFileSync(path.join(dir, 'new.txt'), 'new file\n') +} + +async function runRelay(): Promise { + // Relay mode writes a workspace and the agent-status mirror under userData: never into the + // checkout the fixture runs from. + if (!process.env.FIXTURE_USERDATA) throw new Error('relay mode needs FIXTURE_USERDATA (a scratch dir)') + const alternative = process.env.FIXTURE_COMPOSED_BACKEND + ? await composedBackendFixture(path.join(process.env.FIXTURE_USERDATA, 'composed-backend'), process.env.FIXTURE_COMPOSED_BACKEND, emit) + : null + const port = await startBroker() + const keys = genKeyPair() + const approveAfter = Number(process.env.FIXTURE_APPROVE_AFTER_MS ?? '0') + // "Deny" on the desktop: standing-host.ts removes the pooled session, which closes it. + const rejectAfter = Number(process.env.FIXTURE_REJECT_AFTER_MS ?? '-1') + // The standing host's SILENT decision (standing-host.ts onPeerReady: the pin store read and, for a + // paired phone it has not pinned yet, the late pin, A07-late) takes this long, and is handed to + // connectHostSession as the promise it returns, as standing-host.ts does. FIXTURE_DECIDE=approve + // approves when it settles (a pinned key, or a pairing that recorded it); anything else settles + // without approving, which is the dialog's case (FIXTURE_APPROVE_AFTER_MS then stands for the + // human). Unset: onPeerReady decides nothing and returns nothing, as before. + const decideAfter = Number(process.env.FIXTURE_DECIDE_AFTER_MS ?? '-1') + const decideApproves = process.env.FIXTURE_DECIDE === 'approve' + // A snapshot over the 256 KB chunk size, made of 3-byte code points so a chunk boundary splits + // one: the reassembler must join BYTES before decoding. + const bigSnapshot = 'SNAP-' + '€'.repeat(100_000) + '-END' + let sinks: DetachedSinks | null = null + let sessionCounter = 0 + + let releaseScroll: (() => void) | null = null + let firstScroll = true + let historyCalls = 0 + const holdScroll = process.env.FIXTURE_HISTORY_HOLD === '1' + const pty: HostPtyManager = { + async scrollAttached(clientId, sessionId, up, lines, capture, current) { + historyCalls++ + emit({ event: 'scrollAttached', sessionId, up, lines, capture }) + if (!current()) return { status: 'refused', message: 'This terminal is no longer attached.' } + const outcome = process.env.FIXTURE_HISTORY_RESULT + if (outcome === 'refused' || outcome === 'uncertain') return { status: outcome, message: 'Explicit history fixture ' + outcome } + if (alternative) { + const result = await alternative.scroll(up, lines, capture, current) + if (holdScroll && firstScroll) { + firstScroll = false + await new Promise(resolve => { releaseScroll = resolve; emit({ event: 'history-scroll-held' }) }) + } + return result + } + // Explicit ordinary tmux recorder. Native tests above use actual backend/emulator code. + for (let i = 0; i < lines; i++) pty.write(clientId, sessionId, `\x1b[<${up ? 64 : 65};1;1M`) + return { status: 'input' } + }, + async submitComposed(sessionId, input, current) { + emit({ event: 'submitComposed', sessionId, input }) + if (!current()) return { status: 'refused', message: 'This terminal is no longer attached.' } + if (alternative) return alternative.submit(input, current) + if (process.env.FIXTURE_COMPOSED_INPUT === 'refused') return { status: 'refused', message: 'The fixture pane changed.' } + if (process.env.FIXTURE_COMPOSED_INPUT === 'uncertain') return { status: 'uncertain', message: 'The fixture acknowledgment was lost.' } + return { status: 'delivered' } + }, + async historySearch(sessionId, query) { + emit({ event: 'historySearch', sessionId, query }) + return searchTerminalHistory('old.* Ω 😀\n' + 'recent\n'.repeat(500), query) + }, + createDetached() { + throw new Error('not used') + }, + attachDetached(persistKey, s, options) { + sinks = s + const id = `sess-${++sessionCounter}` + emit({ event: 'attach', persistKey, cols: options?.cols, rows: options?.rows, adaptsToSize: s.adaptsToSize, cwd: options?.cwd, accountId: options?.accountId, agentId: options?.agentId, ownerProjectId: options?.ownerProjectId }) + setTimeout(() => s.onData(`hello ${persistKey}\r\n`), 20) + return id + }, + async captureSnapshot(persistKey) { + return persistKey === 'term-big-1' ? bigSnapshot : `screen of ${persistKey}` + }, + async sessionExists(persistKey) { + // `term-slow-*`: the host is still deciding when the phone gives up on the attach (A40). The + // request has arrived (and the host has reserved the stream) once `probe` is emitted. + if (persistKey.startsWith('term-slow-')) { + emit({ event: 'probe', persistKey }) + await new Promise((r) => setTimeout(r, 300)) + } + return !persistKey.startsWith('term-new-') + }, + write(clientId, sessionId, data) { + emit({ event: 'write', sessionId, data }) + if (data === 'exit\r') sinks?.onExit(7) + else sinks?.onData(`echo:${data}`) + }, + resize(clientId, sessionId, cols, rows) { + emit({ event: 'resize', sessionId, cols, rows }) + if (cols === 100) sinks?.onSize?.({ cols: 132, rows: 43 }) + }, + setFlow() {}, + kill(clientId, sessionId) { + emit({ event: 'kill', sessionId }) + alternative?.close() + }, + // A12 for nodes of the desktop's SSH projects (`remoteNodes` below): the keys typed on THAT host + // over its master (PtyManager.backgroundWriteOver). A node id containing `gone` stands for a + // session the host does not have. + async backgroundWriteOver(persistKey, data, sshRemote) { + emit({ event: 'sendKeysOver', nodeId: persistKey, keys: data, controlPath: sshRemote.controlPath }) + return !persistKey.includes('gone') + } + } + if (process.env.FIXTURE_COMPOSED_INPUT === 'unsupported') delete pty.submitComposed + if (process.env.FIXTURE_HISTORY_SCROLL === 'unsupported') delete pty.scrollAttached + if (process.env.FIXTURE_HISTORY_ADVERTISE_FALSE === '1') { + // Explicit negotiation seam: the actual attach response omits the capability once, while + // the recorder route remains observable if a broken client sends an unadvertised RPC. + const route = pty.scrollAttached + let read = 0 + Object.defineProperty(pty, 'scrollAttached', { configurable: true, + get: () => ++read === 1 ? undefined : route }) + } + + // Explicit test-only control channel; product traffic still crosses the actual E2EE handlers. + if (process.env.FIXTURE_HISTORY_SCROLL || holdScroll || process.env.FIXTURE_HISTORY_ADVERTISE_FALSE === '1') { + let controls = Promise.resolve() + createInterface({ input: process.stdin }).on('line', line => { + controls = controls.then(async () => { + if (line === 'history-state') emit({ event: 'history-state', calls: historyCalls }) + else if (line === 'history-release') { + if (!releaseScroll) throw new Error('No held history fixture response') + const release = releaseScroll; releaseScroll = null; release() + } else if (alternative) await alternative.control(line) + else throw new Error('No native history fixture backend') + emit({ event: 'history-control-done', command: line }) + }).catch(() => emit({ event: 'fatal', message: 'History fixture control failed' })) + }) + } + + + const store = await seedDesktopState() + // The folder the WorkspaceStore just wrote the project file into (`project.cwd` in projects.list). + const [projectDir] = store.localProjectCwds() + if (!projectDir) throw new Error('the seeded workspace has no local project folder') + seedGitRepo(projectDir) + + let session: HostSession | null = null + const approveNow = (): void => { + if (session && !session.isApproved()) { + session.approve() + emit({ event: 'approved' }) + } + } + session = connectHostSession({ + url: `ws://127.0.0.1:${port}`, + token: 'host-room', + ourKeys: keys, + pty, + getLatestCanvas: () => null, + subscribeCanvas: () => () => {}, + applyMutation: () => {}, + // The desktop's own assembly (listProjectsOutput in src/main/index.ts calls the same function): + // the store's read-only load, the mirror file, and the session names, between the markers. + listProjects: () => + buildProjectsListBlob({ workspace: store, userDataDir: platform().userDataDir, listSessions: async () => ['nt-term-abc-1'] }), + // A74-refresh: the `lan` field beside the blob, from the desktop's own reporter (the QR's + // `pairingNetworkIPv4` over the interfaces, and the sealed answer's host-key reader). The interfaces are + // the test's (FIXTURE_LAN_ADDRESS, as a Wi-Fi adapter beside loopback), never this machine's, and + // the host keys/config Include root come from FIXTURE_SSH_HOST_KEY_DIR or a dir that does not + // exist, never /etc. PairingInteropTest also lays out recursive external Includes under scratch. + // `FIXTURE_NO_LAN=1` stands for a desktop that predates the field. + ...(process.env.FIXTURE_NO_LAN === '1' + ? {} + : { + lanReport: createHostLanReporter({ + platform: 'linux', + interfaces: () => ({ + lo: [{ address: '127.0.0.1', family: 'IPv4', internal: true }], + ...(process.env.FIXTURE_LAN_ADDRESS + ? { wlan0: [{ address: process.env.FIXTURE_LAN_ADDRESS, family: 'IPv4', internal: false }] } + : {}) + }), + sshHostKeyDirs: [process.env.FIXTURE_SSH_HOST_KEY_DIR || path.join(platform().userDataDir, 'no-ssh-host-keys')] + }) + }), + // A viewer on a node is an Eco shield and a size ceiling on the desktop (A18): the phone must + // never leave one behind. + remoteViewer: { + attached: (nodeId) => emit({ event: 'viewer-attached', nodeId }), + detached: (nodeId) => emit({ event: 'viewer-detached', nodeId }) + }, + // A29: the git bridge both phone hosts serve (`hostBridge.git` in src/main/index.ts is this same + // class), jailed like production's: the canvas cwds (none here) plus every local project folder, + // read from the store exactly as `workspaceRoots` reads it. `FIXTURE_NO_GIT=1` stands for a + // desktop that serves no git bridge. + ...(process.env.FIXTURE_NO_GIT === '1' ? {} : { git: new GitService() }), + extraRoots: () => store.localProjectCwds(), + registerNode: async (projectId, node) => { + emit({ event: 'registerNode', projectId, node }) + return true + }, + destroyNode: async (nodeId) => { + emit({ event: 'destroyNode', nodeId }) + }, + nodeActions: { + wake: (nodeId) => (emit({ event: 'wake', nodeId }), true), + refresh: (nodeId) => (emit({ event: 'refresh', nodeId }), true), + rename: (nodeId, title) => (emit({ event: 'rename', nodeId, title }), true), + // A12. `FIXTURE_NO_SENDKEYS=1` stands for a desktop that predates the verb; a node id + // containing `gone` stands for a session the background write could not reach. + ...(process.env.FIXTURE_NO_SENDKEYS === '1' + ? {} + : { sendKeys: async (nodeId: string, keys: string) => (emit({ event: 'sendKeys', nodeId, keys }), !nodeId.includes('gone')) }) + }, + kanban: { + ensureBoard: async (projectId) => (emit({ event: 'ensureBoard', projectId }), [{ id: 'c1', title: 'To Do', color: '#0a84ff' }]), + setCardColumn: async (projectId, nodeId, columnId) => (emit({ event: 'setCardColumn', projectId, nodeId, columnId }), true), + editCardLabels: async (projectId, nodeId, edit) => ( + emit({ event: 'editCardLabels', projectId, nodeId, edit }), + { edited: true, labels: [{ id: 'l1', name: 'bug', color: 'red' as const }], cardLabelIds: ['l1'] } + ) + }, + inbox: { + // A pendingId ending in `-expired` stands for a hold that already ended (the real writer finds + // no request file and answers `gone`, audit A06). + answerPermission: async (nodeId, pendingId, decision, suggestionIndex) => ( + emit({ event: 'answer', nodeId, pendingId, decision, ...(suggestionIndex !== undefined ? { suggestionIndex } : {}) }), pendingId.endsWith('-expired') ? 'gone' : 'sent' + ), + ...(process.env.FIXTURE_NO_STRUCTURED_QUESTIONS === '1' ? {} : { answerQuestion: async (nodeId: string, pendingId: string, selections: number[][]): Promise<'gone' | 'sent'> => ( + emit({ event: 'question-answer', nodeId, pendingId, selections }), pendingId.endsWith('-expired') ? 'gone' : 'sent' + ) }), + ackRead: (nodeId) => emit({ event: 'ack', nodeId }) + }, + // A09/A12: which nodes belong to one of the desktop's SSH projects. `ssh-*` ids do, reached over + // a connected master; `ssh-offline-*` ones belong to a project whose master is down. Every other + // id is local, as before. + remoteNodes: { + resolve: (nodeId) => { + if (!nodeId.startsWith('ssh-')) return null + if (nodeId.startsWith('ssh-offline-')) return { where: 'me@box' } + return { where: 'me@box', sshRemote: { controlPath: '/cm/box.sock', conn: { host: 'box', user: 'me' }, remoteCwd: '~/repo' } } + } + }, + // A33/A72: the desktop's REAL resolver decides what a phone-started session is created with — + // folder, account, agent and pane owner — over a fake index (local folder project p1) and one + // logged-in managed Claude account. Kept apart from the store behind `projects.list` on purpose: + // the fixed `/repo` lets RelayInteropTest assert the folder without knowing the scratch dir. + newSessions: createHostNewSessions({ + projectTargetInfo: (projectId) => (projectId === 'p1' ? { cwd: '/repo' } : null), + claudeAccounts: () => [{ id: 'acct-1' }] + }), + onPeerReady: (s) => { + emit({ event: 'peer-ready', sas: s.sas(), pub: s.peerPublicKeyB64() }) + if (approveAfter >= 0) setTimeout(approveNow, approveAfter) + if (rejectAfter >= 0) setTimeout(() => (emit({ event: 'rejected' }), session?.close()), rejectAfter) + if (decideAfter < 0) return + return new Promise((resolve) => + setTimeout(() => { + if (decideApproves) approveNow() + emit({ event: 'decided', approved: decideApproves }) + resolve() + }, decideAfter) + ) + }, + onClose: () => emit({ event: 'host-close' }) + }) + + emit({ ready: true, relayUrl: `ws://127.0.0.1:${port}`, clientToken: 'client-room', hostPublicKeyB64: publicKeyToB64(keys.publicKey) }) +} + +// ---- mode "pair" ----------------------------------------------------------------------------------- + +async function runPair(): Promise { + // Test seam: the OS as the fixture and the pairing service see it. Both read `process.platform` at + // call time; node's own modules captured the real one at startup and are unaffected. Lets a Linux + // run check what a Windows run of this mode does (PairingInteropTest, audit A70). + const asPlatform = process.env.FIXTURE_PROCESS_PLATFORM + if (asPlatform) Object.defineProperty(process, 'platform', { value: asPlatform }) + // The service writes `.nodeterm/agent.json` (a device entry carrying a live bearer token) and + // `.ssh/authorized_keys` under `os.homedir()`, which reads HOME on POSIX and USERPROFILE on Windows. + // Refuse to start unless the caller named that dir as its scratch home, so a caller that set the + // wrong variable fails here instead of pairing a test device into a real profile (audit A70). + const scratch = process.env.FIXTURE_HOME + if (!scratch || path.resolve(os.homedir()) !== path.resolve(scratch)) { + throw new Error(`pair mode needs os.homedir() to be FIXTURE_HOME; it is ${os.homedir()}, FIXTURE_HOME is ${scratch ?? 'unset'}`) + } + const keys = genKeyPair() + const api = http.createServer((req, res) => { + let body = '' + req.on('data', (c) => (body += c)) + req.on('end', () => { + emit({ event: 'api', path: req.url, body: JSON.parse(body || '{}') }) + if (req.url === '/v1/relay/device') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ deviceToken: 'device-token-xyz', hostId: 'minted-host-id', exp: 123 })) + } else { + res.writeHead(404).end() + } + }) + }) + await new Promise((r) => api.listen(0, '127.0.0.1', r)) + const apiPort = (api.address() as { port: number }).port + const withRelay = process.env.FIXTURE_RELAY === '1' + const service = createPairingService( + { + getSettings: () => ({ ...DEFAULT_SETTINGS, phoneAccessEnabled: withRelay }), + getEntitlement: () => null, + loadHostKeyPair: async () => keys, + relayEndpoint: 'wss://relay.example.test', + apiBase: `http://127.0.0.1:${apiPort}`, + relayAllowed: () => withRelay, + pinRelayKey: async (pub) => emit({ event: 'pin', pub }), + revokeRelayKey: async (pub) => (emit({ event: 'revoke-relay-key', pub }), { persisted: true, killed: true }), + // A07-late. index.ts asks `stillPaired` inside the pin store's queue (pinApprovedDeviceIf, which + // reads Electron's userData and is vitest's to cover); what this mode checks is the service's + // own half: the key the phone sent is recorded, recognized later, and forgotten on revoke. + pinRelayKeyIfPaired: async (pub, stillPaired) => { + const paired = await stillPaired() + if (paired) emit({ event: 'late-pin', pub }) + return paired + } + }, + // The direct-SSH path on every OS (audit A70): on win32 the service pairs relay-only (no key, the + // QR says `ssh:false`), which pairing-service.windows.test.ts covers. `platform` only separates + // win32 from the rest, so Linux and macOS run exactly what they always did. + { + timeoutMs: 60_000, + platform: process.platform === 'win32' ? 'linux' : process.platform, + sshHostKeyDirs: [process.env.FIXTURE_SSH_HOST_KEY_DIR || path.join(scratch, 'no-ssh-host-keys')] + } + ) + // A07-late: after a pairing, ask the service what the standing host asks on a relay handshake from + // each of these keys (the phone's own and a stranger's, chosen by the test), then revoke every + // device and ask again. + const lateKeys = (process.env.FIXTURE_LATE_PIN_KEYS ?? '').split(',').filter(Boolean) + const askLate = async (when: string): Promise => { + for (const pub of lateKeys) emit({ event: 'late-approval', when, pub, approved: await service.approvePairedRelayKey(pub) }) + } + const started = await service.start((done) => { + emit({ event: 'done', ...done }) + if (!done.ok || !lateKeys.length) return + void (async () => { + await askLate('paired') + for (const device of await service.listDevices()) await service.revokeDevice(device.id) + await askLate('revoked') + })().catch((err) => emit({ event: 'fatal', message: String((err as Error)?.stack ?? err) })) + }) + emit({ ready: true, payload: started.payload, hostPublicKeyB64: publicKeyToB64(keys.publicKey), relayPlan: started.relayPlan }) +} + +/** Actual sealed legacy pairing, approved E2EE session, guarded writer and exact revoker. The + * public pin-store adapter and human SAS decision are explicit in-memory fixture boundaries. */ +async function runLegacyPairing(): Promise { + const scratch = process.env.FIXTURE_HOME + if (!scratch || path.resolve(os.homedir()) !== path.resolve(scratch) || + path.resolve(platform().userDataDir) !== path.resolve(scratch)) throw new Error('legacy pairing requires one scratch home/profile') + const keys = genKeyPair() + const port = await startBroker() + const endpoint = `ws://127.0.0.1:${port}` + let pins = emptyApprovedDevices() + const sessions = new Map() + const killed: string[] = [] + const revoker = createRevoker({ + load: async () => pins, + save: async next => { pins = next }, + onRevoke: key => { + killed.push(key) + for (const session of sessions.values()) if (session.peerPublicKeyB64() === key) session.close() + } + }) + const api = http.createServer((req, res) => { + req.resume() + req.on('end', () => { + if (req.url !== '/v1/relay/device') { res.writeHead(404).end(); return } + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ deviceToken: 'inert-legacy-device-token', hostId: 'legacy-fixture-host', exp: 123 })) + }) + }) + await new Promise(resolve => api.listen(0, '127.0.0.1', resolve)) + const apiPort = (api.address() as { port: number }).port + const service = createPairingService({ + getSettings: () => ({ ...DEFAULT_SETTINGS, phoneAccessEnabled: true }), + getEntitlement: () => null, loadHostKeyPair: async () => keys, + relayEndpoint: endpoint, apiBase: `http://127.0.0.1:${apiPort}`, relayAllowed: () => true, + pinRelayKey: async key => { pins = pinDevice(pins, key) }, + revokeRelayKey: key => revoker.revoke(key) + }, { timeoutMs: 60_000, platform: 'linux', sshHostKeyDirs: [path.join(scratch, 'no-public-host-keys')], + interfaces: () => ({ fixture: [{ address: '10.23.45.67', family: 'IPv4', internal: false }] }) }) + const store = await seedDesktopState() + const legacyRelayPairings = process.env.FIXTURE_LEGACY_CLOSE_DURING_PROOF === '1' ? { + ...service.legacyRelayPairings, + inspect: async (pairingId: string, peerKey: string) => { + const found = await service.legacyRelayPairings.inspect(pairingId, peerKey) + sessions.get('subject')?.close() + emit({ event: 'legacy-mid-proof-close' }) + return found + } + } : service.legacyRelayPairings + for (const room of ['subject', 'other']) { + const session = connectHostSession({ + ...(process.env.FIXTURE_LEGACY_UNSUPPORTED === '1' ? {} : { legacyRelayPairings }), + url: endpoint, token: `host-${room}`, ourKeys: keys, + pty: { + createDetached() { throw new Error('no terminal is created in this fixture') }, + attachDetached() { throw new Error('no terminal is attached in this fixture') }, + captureSnapshot: async () => '', sessionExists: async () => false, + write() { throw new Error('no terminal input is allowed') }, resize() {}, setFlow() {}, kill() {} + }, + getLatestCanvas: () => null, subscribeCanvas: () => () => {}, applyMutation() {}, + listProjects: () => buildProjectsListBlob({ workspace: store, userDataDir: scratch, listSessions: async () => [] }), + onPeerReady: session => { + const peer = session.peerPublicKeyB64() + if (!peer) throw new Error('a handshake must identify its peer') + if (isPinned(pins, peer) || (room === 'subject' && process.env.FIXTURE_LEGACY_APPROVE !== '0')) { + // This is the fixture's explicit human decision, never an association-based approval. + const approve = (): void => { pins = pinDevice(pins, peer); session.approve() } + const delay = Number(process.env.FIXTURE_LEGACY_APPROVE_AFTER_MS ?? '0') + if (room === 'subject' && !isPinned(pins, peer) && delay > 0) setTimeout(approve, delay) + else approve() + } + emit({ event: 'legacy-peer', room, approved: session.isApproved(), key: peer }) + }, + onClose: () => emit({ event: 'legacy-close', room }) + }) + sessions.set(room, session) + } + const state = async (): Promise => { + const obj: { devices: Array<{ id: string; relayBoxKey?: string }> } = JSON.parse( + await fs.promises.readFile(path.join(scratch, '.nodeterm/agent.json'), 'utf8')) + emit({ event: 'legacy-state', devices: obj.devices.map(({ id, relayBoxKey }) => ({ id, relayBoxKey })), + pins: pins.pubkeys, killed, live: [...sessions].map(([room, session]) => ({ room, approved: session.isApproved() })) }) + } + const pair = () => service.start(done => emit({ event: 'legacy-paired', ...done })) + let tail = Promise.resolve() + createInterface({ input: process.stdin }).on('line', line => { + tail = tail.then(async () => { + if (line === 'start-pair') emit({ event: 'legacy-pair-ready', payload: (await pair()).payload }) + else if (line === 'state') await state() + else if (/^revoke:[0-9a-f-]{36}$/.test(line)) { + emit({ event: 'legacy-revoked', result: await service.revokeDevice(line.slice(7)) }) + await state() + } else throw new Error('unknown fixture command') + }).catch(() => emit({ event: 'fatal', message: 'legacy fixture command failed' })) + }) + emit({ ready: true, payload: (await pair()).payload, relayUrl: endpoint, hostPublicKeyB64: publicKeyToB64(keys.publicKey) }) +} + +/** No sockets: real mirror producer and desktop command assembler checked by the Kotlin client. */ +async function runLaunchParity(): Promise { + const codexApprovalValues = codexApprovalValuesFrom(process.env.FIXTURE_CODEX_HELP) + const autoSupported = process.env.FIXTURE_CLAUDE_AUTO === 'true' + const store = await seedDesktopState({ autoSupported, ...(codexApprovalValues ? { codexApprovalValues } : {}) }) + const blob = await buildProjectsListBlob({ workspace: store, userDataDir: platform().userDataDir, listSessions: async () => [] }) + const commands = Object.entries(AGENT_CONFIG).flatMap(([agentId]) => ALL_PERMISSION_MODES.map((mode) => { + const permissionMode = agentId === 'claude' ? gatePermissionMode(mode, autoSupported) : mode + const facts = { agentId, permissionMode, approvalCaps: { codexApprovalValues }, sharedIdentity: false } + return { + agent: agentId, mode, + launch: assembleLaunchCommand(facts, {}).command, + resume: assembleResumeCommand({ ...facts, sessionId: 'interop-session' }, {}).command + } + })) + emit({ ready: true, blob, commands }) +} + +/** No sockets: these are production wire producers; only the kernel's interface table is fake. */ +async function runPairingNetwork(): Promise { + const userData = process.env.FIXTURE_USERDATA + if (!userData) throw new Error('pairing-network needs FIXTURE_USERDATA (a scratch dir)') + const ipv4 = (address: string): { address: string; family: string; internal: boolean } => + ({ address, family: 'IPv4', internal: false }) + // Docker and VPN deliberately precede the physical adapter in the kernel's enumeration. + const base: PairingInterfaces = { + docker0: [ipv4('172.17.0.1')], wg0: [ipv4('10.7.0.2')], + lo: [{ address: '127.0.0.1', family: 'IPv4', internal: true }], + wlan0: [ipv4('192.168.1.42')] + } + let interfaces = base + let selected = 'wlan0' + // The real desktop injects the same fresh settings getter into pairing and this reporter. + // Reuse ONE reporter while the table and selection change, so a captured initial value fails. + const report = createHostLanReporter({ + platform: 'linux', interfaces: () => interfaces, getPairingInterface: () => selected, + sshHostKeyDirs: [process.env.FIXTURE_SSH_HOST_KEY_DIR || path.join(userData, 'no-ssh-host-keys')] + }) + const keys = genKeyPair() + const hostKey = publicKeyToB64(keys.publicKey) + const cases: { name: string; payload: string | null; lan: Awaited>; choices: ReturnType }[] = [] + const capture = async (name: string): Promise => { + const host = pairingNetworkIPv4(interfaces, selected) + cases.push({ + name, + payload: host ? buildPairingPayload({ host, user: 'fixture', token: 'fixture-pair-token', + pairPort: 23456, name: 'Adapter fixture', hostKey, + relay: { hostId: 'fixture-relay-host', hostPublicKeyB64: hostKey, relayEndpoint: 'wss://relay.example.test' } + }) : null, + lan: await report(), choices: pairingNetworkChoices(interfaces) + }) + } + await capture('selected') + interfaces = { ...base, wlan0: [ipv4('192.168.1.77')] } + await capture('dhcp-moved') + interfaces = { docker0: base.docker0, wg0: base.wg0, lo: base.lo } + await capture('selected-missing') + selected = 'wg0' + await capture('selection-changed') + interfaces = base + selected = '' + await capture('automatic-physical') + interfaces = { wg0: base.wg0 } + await capture('automatic-virtual-only') + // Windows has a relay-only pairing payload. Its OS default-route hint is accepted only when it + // is a current eligible address; an explicit adapter still takes precedence over that hint. + const multiple = { ...base, wlan0: [ipv4('192.168.1.42'), ipv4('192.168.1.99')] } + const windowsPayload = (selection: string, route: string): string => { + const host = pairingNetworkIPv4(multiple, selection, route) + if (!host) throw new Error('fixture has no eligible route') + return buildPairingPayload({ host, user: 'fixture', token: 'fixture-pair-token', pairPort: 23456, + name: 'Windows adapter fixture', hostKey, ssh: false, + relay: { hostId: 'fixture-relay-host', hostPublicKeyB64: hostKey, relayEndpoint: 'wss://relay.example.test' } + }) + } + emit({ ready: true, cases, + windows: { currentRoute: windowsPayload('', '192.168.1.99'), staleRoute: windowsPayload('', '203.0.113.8'), + explicitAdapter: windowsPayload('wg0', '192.168.1.99'), + lan: await createHostLanReporter({ platform: 'win32', interfaces: () => multiple, getPairingInterface: () => 'wg0', + sshHostKeyDirs: [process.env.FIXTURE_SSH_HOST_KEY_DIR || path.join(userData, 'no-ssh-host-keys')] })() } + }) +} + +const mode = process.argv[2] +if (mode === 'never-ready') { + setInterval(() => {}, 60_000) +} else if (mode !== 'project-launch') { + ;(mode === 'pair' ? runPair() : mode === 'legacy-pairing' ? runLegacyPairing() : mode === 'ack-sweep' ? runAckSweep() : mode === 'launch-parity' ? runLaunchParity() : mode === 'pairing-network' ? runPairingNetwork() : runRelay()).catch((err) => { + emit({ event: 'fatal', message: String((err as Error)?.stack ?? err) }) + process.exit(1) + }) +} diff --git a/android/protocol/src/test/interop/launch-native.ts b/android/protocol/src/test/interop/launch-native.ts new file mode 100644 index 0000000000..44419f3234 --- /dev/null +++ b/android/protocol/src/test/interop/launch-native.ts @@ -0,0 +1,38 @@ +// Native PTY boundary recorder. The real manager constructs the shell/env; the Kotlin client sees +// the resulting output and sends its launch line through the real host stream. No shell runs. +// This pure env selector remains the production helper. Only executable discovery is recorded; +// the full relative import avoids the manager-only ./exec-path alias redirecting back here. +export { envPathKey } from '../../../../../src/core/exec-path' +export function findExecutableSync(): null { return null } +export function findInPathString(): null { return null } +export function findInLoginPath(): null { return null } +export function shellPathNow(): string { return '/usr/bin:/bin' } +export async function resolveShellPath(): Promise { return shellPathNow() } +// Disabling tmux in Settings does not suppress init's discovery/source-file side-call. The fixture +// must replace discovery too, and reject any unexpected manager subprocess rather than reach a +// developer's running tmux server. These are process boundaries, not settings/trust substitutes. +export function findFixedTmux(): null { return null } +export function bundledTmuxPath(): null { return null } +export function findCommand(): false { return false } +export function tmuxInstall(): null { return null } +export function execFile(): never { throw new Error('managed launch fixture refused a subprocess') } +export function execFileSync(): never { throw new Error('managed launch fixture refused a subprocess') } + +export function spawn(file: string, args: string[], options: { env: Record; cwd: string }) { + let data: ((text: string) => void) | undefined + let exit: ((event: { exitCode: number }) => void) | undefined + return { + pid: 1, + onData(callback: (text: string) => void) { + data = callback + setTimeout(() => callback(`launch-env:${options.env.A72_PROJECT_ENV ?? 'absent'} shell:${file}\r\n`), 10) + }, + onExit(callback: (event: { exitCode: number }) => void) { exit = callback }, + write(text: string) { + process.stdout.write(JSON.stringify({ event: 'launch-write', text, cwd: options.cwd, + projectEnv: options.env.A72_PROJECT_ENV ?? null, shell: file }) + '\n') + data?.(`echo:${text}`) + }, + resize() {}, pause() {}, resume() {}, kill() { exit?.({ exitCode: 0 }) } + } +} diff --git a/android/protocol/src/test/interop/launch-os.ts b/android/protocol/src/test/interop/launch-os.ts new file mode 100644 index 0000000000..7ea77b29b1 --- /dev/null +++ b/android/protocol/src/test/interop/launch-os.ts @@ -0,0 +1,11 @@ +// A scoped virtual SSH-style home abstraction, without changing HOME/CODEX_HOME. Only the dedicated +// project-launch bundle resolves os imports here. Never used in a shipped build or another fixture. +import * as real from 'node:os' +import path from 'node:path' +export * from 'node:os' +export function homedir(): string { + const scratch = process.env.FIXTURE_USERDATA + if (!scratch || !path.isAbsolute(scratch)) throw new Error('managed launch needs absolute FIXTURE_USERDATA') + return path.join(scratch, 'virtual-home') +} +export default { ...real, homedir } diff --git a/android/protocol/src/test/interop/managed-native.ts b/android/protocol/src/test/interop/managed-native.ts new file mode 100644 index 0000000000..a2cdc11776 --- /dev/null +++ b/android/protocol/src/test/interop/managed-native.ts @@ -0,0 +1,116 @@ +// Explicit native/process boundary recorder for managed-session-fixture.ts. No terminal, shell, +// CLI or tmux server runs. Receipt process birth is read from this recorder process by the actual +// managed-pane implementation; the surrounding planner/coordinator/PtyManager are production. +import { EventEmitter } from 'node:events' +import { promisify } from 'node:util' +// Preserve the actual pure env selector; the manager-only ./exec-path alias cannot match this +// full relative path. Native process execution and executable discovery remain explicit recorders. +export { envPathKey } from '../../../../../src/core/exec-path' + +interface Pane { session: string; creationId: string; paneId: string; tty: string; created: string; + cwd: string; shell: string; projectEnv: string | null; accountDir: string | null } +const panes = new Map() +const created: Array> = [] +const launched: Array> = [] +const emit = (value: unknown): void => { process.stdout.write(JSON.stringify(value) + '\n') } +export function nativeProof(): { created: typeof created; launched: typeof launched } { return { created, launched } } +export function findExecutableSync(): null { return null } +export function findInPathString(program: string): string | null { return program === 'tmux' ? '/fixture/tmux' : null } +export function shellPathNow(): string { return '/usr/bin:/bin' } +export async function resolveShellPath(): Promise { return shellPathNow() } +export function findFixedTmux(): string { return '/fixture/tmux' } +export function bundledTmuxPath(): null { return null } +export function findCommand(): false { return false } +export function tmuxInstall(): null { return null } + +function target(args: readonly string[]): Pane { + const raw = args[args.indexOf('-t') + 1] + const pane = raw?.startsWith('%') ? [...panes.values()].find((p) => p.paneId === raw) + : panes.get(raw?.replace(/^=/, '').replace(/:$/, '')) + if (!pane) throw new Error('managed native recorder has no such pane') + return pane +} +function output(file: string, args: readonly string[], body?: string): string { + if (file === 'ps') { + const pane = [...panes.values()].find((p) => p.tty === args[args.indexOf('-t') + 1]) + if (!pane) throw new Error('managed native recorder has no such tty') + return `${process.pid} ${process.pid} Ss+ ${pane.shell}\n` + } + if (file !== '/fixture/tmux' || args[0] !== '-L' || args[1] !== 'node-terminal') + throw new Error('managed native recorder refused an unexpected subprocess') + if (args.includes('source-file') || args.includes('set-option') || args.includes('delete-buffer')) return '' + if (args.includes('show-options')) return '' + if (args.includes('has-session')) { + try { target(args); return '' } + catch { throw Object.assign(new Error('managed native recorder has no such session'), { code: 1 }) } + } + if (args.includes('list-panes')) { + const p = target(args) + return `${p.session}|${p.created}|${p.paneId}|${process.pid}|${p.creationId}\n` + } + if (args.includes('display-message')) { + const p = target(args) + if (args.at(-1) === '#{pane_pid}|#{pane_tty}|#{pane_current_command}|#{pane_id}') + return `${process.pid}|${p.tty}|${p.shell.split('/').at(-1)}|${p.paneId}\n` + } + if (args.includes('load-buffer')) { + const p = target(args), at = args.indexOf('if-shell'), condition = args[at + 4] + if (typeof body !== 'string' || !condition || + ![p.session, p.created, p.creationId, String(process.pid)].every((part) => condition.includes(part))) + return 'nt-managed-refused\n' + const proof = { event: 'managed-launch', body, nodeId: p.session.slice(3), cwd: p.cwd, + shell: p.shell, projectEnv: p.projectEnv, accountDir: p.accountDir, textOnStdin: args.every((arg) => !arg.includes(body)) } + launched.push(proof); emit(proof) + return 'nt-managed-delivered\n' + } + if (args.includes('capture-pane')) return '' + throw new Error('managed native recorder refused unexpected tmux arguments') +} + +// Both callback and Node's custom promisify contract are supported. Promise.child.stdin is the +// real PtyManager runWithStdin seam; no implementation method is replaced after construction. +export function execFile(file: string, args: readonly string[], options?: unknown, callback?: (...args: unknown[]) => void): unknown { + const cb = typeof options === 'function' ? options as (...args: unknown[]) => void : callback + queueMicrotask(() => { try { cb?.(null, output(file, args), '') } catch (error) { cb?.(error, '', '') } }) + return { stdin: new EventEmitter(), kill() {} } +} +Object.defineProperty(execFile, promisify.custom, { value: (file: string, args: readonly string[]) => { + let resolve!: (value: { stdout: string; stderr: string }) => void + let reject!: (error: unknown) => void + const promise = new Promise<{ stdout: string; stderr: string }>((yes, no) => { resolve = yes; reject = no }) + let settled = false + const stdin = new EventEmitter() as EventEmitter & { end(body: string): void } + const settle = (body?: string): void => { + if (settled) return + settled = true + try { resolve({ stdout: output(file, args, body), stderr: '' }) } catch (error) { reject(error) } + } + stdin.end = (body) => settle(body) + Object.assign(promise, { child: { stdin } }) + if (!args.includes('load-buffer')) queueMicrotask(() => settle()) + return promise +} }) +export function execFileSync(file: string, args: readonly string[], options?: { encoding?: string }): string | Buffer { + const result = output(file, args) + return options?.encoding ? result : Buffer.from(result) +} + +export function spawn(file: string, args: string[], options: { cwd: string; env: Record }) { + if (file !== '/fixture/tmux' || !args.includes('new-session') || args.includes('-A') || args.includes('-D')) + throw new Error('managed native recorder requires exclusive create-only tmux') + const session = args[args.indexOf('-s') + 1] + if (!session || panes.has(session)) throw new Error('managed native recorder refuses a occupied session') + const marker = args.find((arg) => arg.startsWith('NODETERM_MANAGED_CREATION_ID=')) + if (!marker) throw new Error('managed native recorder requires the creation marker') + const p: Pane = { session, creationId: marker.split('=')[1], paneId: '%' + panes.size, + tty: '/dev/pts/' + panes.size, created: String(Math.floor(Date.now() / 1000)), cwd: options.cwd, + shell: args.at(-1)!, projectEnv: options.env.MANAGED_TEST_ENV ?? null, accountDir: options.env.CLAUDE_CONFIG_DIR ?? null } + panes.set(session, p) + const proof = { event: 'managed-spawn', nodeId: session.slice(3), creationId: p.creationId, + cwd: p.cwd, shell: p.shell, projectEnv: p.projectEnv, accountDir: p.accountDir, createOnly: true } + created.push(proof); emit(proof) + let exit: ((event: { exitCode: number }) => void) | undefined + return { pid: process.pid, onData() {}, onExit(fn: typeof exit) { exit = fn }, + write(): never { throw new Error('managed launch must use the attested tmux stdin path') }, + resize() {}, pause() {}, resume() {}, kill() { exit?.({ exitCode: 0 }) } } +} diff --git a/android/protocol/src/test/interop/managed-session-fixture.ts b/android/protocol/src/test/interop/managed-session-fixture.ts new file mode 100644 index 0000000000..493379e3ab --- /dev/null +++ b/android/protocol/src/test/interop/managed-session-fixture.ts @@ -0,0 +1,91 @@ +// Real Kotlin request -> selected-profile SshActionsService -> ManagedTerminals -> host planner +// -> WorkspaceStore save chain and PtyManager create/verify/deliver. The managed-native recorder +// replaces only native PTY/tmux/foreground probes and login-PATH discovery. No shell/CLI runs; +// the separate real native-Pty/tmux runtime gate is required before release claims. +import fs from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' +import readline from 'node:readline' +import { initPlatform } from '../../../../../src/core/platform' +import { fakePlatform } from '../../../../../src/core/platform-fake' +import { WorkspaceStore } from '../../../../../src/core/workspace-store' +import { ProjectTrustStore, localTrustKey, hashTrustContent } from '../../../../../src/core/project-trust-store' +import { makeProjectSpawnOverrides } from '../../../../../src/core/project-spawn-overrides' +import { projectTrustContent, type ProjectSettingsFileV1 } from '../../../../../src/shared/project-settings' +import { DEFAULT_SETTINGS, UNKNOWN_CLAUDE_CLI_CAPS, UNKNOWN_CODEX_CLI_CAPS, UNKNOWN_GROK_CLI_CAPS, type Workspace } from '../../../../../src/shared/types' +import { createManagedTerminalPlanner } from '../../../../../src/core/managed-terminal-plan' +import { ManagedTerminals } from '../../../../../src/core/managed-terminals' +import { PtyManager, resolveLocalSessionShell } from '../../../../../src/core/pty-manager' +import { registerClaudeAccountsSource } from '../../../../../src/core/claude-config-dir' +import { SshActionsService } from '../../../../../src/core/ssh-actions' +import { nativeProof } from './managed-native' + +const emit = (value: unknown): void => { process.stdout.write(JSON.stringify(value) + '\n') } +async function main(): Promise { + if (process.argv[2] !== 'managed-session' || !process.env.FIXTURE_USERDATA || !process.env.FIXTURE_HOME) + throw new Error('managed session fixture needs a private selected profile') + const userData = path.resolve(process.env.FIXTURE_USERDATA), home = path.resolve(process.env.FIXTURE_HOME) + if (!userData.startsWith(home + path.sep)) throw new Error('managed session profile must be inside its private home') + await fs.mkdir(os.homedir(), { recursive: true, mode: 0o700 }) + await fs.mkdir(userData, { recursive: true, mode: 0o700 }) + const platform = fakePlatform({ userDataDir: userData }); initPlatform(platform) + const cwd = path.join(home, 'managed-project'); await fs.mkdir(cwd, { mode: 0o700 }) + const store = new WorkspaceStore() + const projectId = 'managed-project' + const workspace: Workspace = { version: 2, activeProjectId: projectId, projects: [{ id: projectId, + name: 'Managed', cwd, color: '#0a84ff', viewport: { x: 0, y: 0, zoom: 1 }, nodes: [], + defaultPermissionMode: 'auto', defaultAccountId: 'fixture-account' }] } + await store.save(workspace) + const document: ProjectSettingsFileV1 = { version: 1, rev: 1, savedAt: 'fixture', + agents: { defaultAgentId: 'claude', launchCmd: 'trusted-wrapper claude', env: { MANAGED_TEST_ENV: 'trusted' } }, + terminal: { shell: '/bin/sh' } } + await fs.writeFile(path.join(cwd, '.nodeterm/settings.json'), JSON.stringify(document)) + const trust = new ProjectTrustStore() + if (process.env.FIXTURE_MANAGED_TRUSTED === '1') for (const family of ['agents', 'shell'] as const) { + const content = projectTrustContent(family, document) + if (content === null) throw new Error('fixture trust family has no content') + await trust.record(localTrustKey(cwd), family, hashTrustContent(content), 'fixture') + } + const settings = structuredClone(DEFAULT_SETTINGS) + settings.tmuxEnabled = true; settings.defaultShell = '/bin/bash' + settings.claudePermissionMode = 'bypassPermissions' + settings.agentLaunchCommands = { claude: 'global-wrapper claude' } + settings.claudeAccounts = [{ id: 'fixture-account', label: 'Fixture', color: '#123abc', createdAt: 1 }] + await fs.mkdir(path.join(userData, 'claude-accounts/fixture-account'), { recursive: true, mode: 0o700 }) + registerClaudeAccountsSource(() => settings.claudeAccounts) + const pty = new PtyManager(); pty.init(() => settings) + pty.setProjectSpawnOverrides(makeProjectSpawnOverrides({ readSettings: (id) => store.readProjectSettings(id), + targetInfo: (id) => store.projectTargetInfo(id), trust })) + const planner = createManagedTerminalPlanner({ store, userData, settings: () => settings, trust, + executable: async (program) => ['/bin/sh', '/bin/bash', 'claude', 'trusted-wrapper', 'global-wrapper'].includes(program) ? program : null, + sessionShell: resolveLocalSessionShell, + claudeCaps: async () => ({ ...UNKNOWN_CLAUDE_CLI_CAPS, autoPermissionMode: true, sessionIdFlag: true }), + codexCaps: async () => UNKNOWN_CODEX_CLI_CAPS, grokCaps: async () => UNKNOWN_GROK_CLI_CAPS, + codexIdentity: async () => ({ shared: false, launcherPath: null }), env: {} }) + const coordinator = (): ManagedTerminals => new ManagedTerminals(userData, store, { + supported: () => pty.supportsManagedCreation(), ...planner, + create: async (options, creationId, current) => { + try { return await pty.createManagedHeadless(options, creationId, current) } + catch (error) { console.error('[managed native recorder failure]', String(error)); throw error } + }, + verify: (receipt, current) => pty.verifyManagedPane(receipt, current), + deliver: (receipt, command, current) => pty.deliverManagedLaunch(receipt, command, current) }) + await store.load({ sideline: false }) + let service = new SshActionsService(userData, store, undefined, false, coordinator()) + await service.start() + if (!pty.supportsManagedCreation()) throw new Error('managed native recorder was not installed') + emit({ ready: true, userData, cwd, projectId, nativeBoundary: 'managed-native-recorder', + recorderPid: process.pid, advertisement: path.join(service.root, 'advertisement.json') }) + const lines = readline.createInterface({ input: process.stdin }) + for await (const line of lines) { + if (line === 'snapshot') emit({ event: 'snapshot', file: JSON.parse(await fs.readFile(path.join(cwd, '.nodeterm/project.json'), 'utf8')), + native: nativeProof(), broadcasts: platform.sent }) + else if (line === 'restart') { + await service.stop(); service = new SshActionsService(userData, store, undefined, false, coordinator()) + await service.start(); emit({ event: 'restarted' }) + } else if (line === 'stop') { await service.stop(); emit({ event: 'stopped' }) } + else throw new Error('Unknown managed fixture driver command') + } + await service.stop() +} +void main().catch((error) => { emit({ event: 'fatal', message: String(error) }); process.exit(1) }) diff --git a/android/protocol/src/test/interop/project-launch-fixture.ts b/android/protocol/src/test/interop/project-launch-fixture.ts new file mode 100644 index 0000000000..60ce1c52d2 --- /dev/null +++ b/android/protocol/src/test/interop/project-launch-fixture.ts @@ -0,0 +1,66 @@ +// Real managed launch producer/client contract: WorkspaceStore's settings.json reader + recorded +// family trust -> PtyManager's actual environment/shell builder -> encrypted relay -> Android. +// Only native PTY execution/login PATH/os.home are boundary recorders (bundle-fixture.cjs). +import fs from 'node:fs' +import os from 'os' +import path from 'node:path' +import { startBroker } from './host-fixture' +import { platform } from '../../../../../src/core/platform' +import { PtyManager } from '../../../../../src/core/pty-manager' +import { WorkspaceStore } from '../../../../../src/core/workspace-store' +import { ProjectTrustStore, localTrustKey, hashTrustContent } from '../../../../../src/core/project-trust-store' +import { projectTrustContent, type ProjectSettingsFileV1 } from '../../../../../src/shared/project-settings' +import { makeProjectSpawnOverrides } from '../../../../../src/core/project-spawn-overrides' +import { DEFAULT_SETTINGS, type Workspace } from '../../../../../src/shared/types' +import { createHostNewSessions } from '../../../../../src/main/remote/host-new-sessions' +import { connectHostSession } from '../../../../../src/main/remote/host-service' +import { genKeyPair, publicKeyToB64 } from '../../../../../src/main/remote/e2ee' + +const emit = (event: unknown): void => { process.stdout.write(JSON.stringify(event) + '\n') } +async function main(): Promise { + if (process.argv[2] !== 'project-launch' || !process.env.FIXTURE_USERDATA) throw new Error('launch fixture scope missing') + fs.mkdirSync(os.homedir(), { recursive: true }) + const cwd = path.join(platform().userDataDir, 'launch-project') + const sub = path.join(cwd, 'sub') + fs.mkdirSync(sub, { recursive: true }) + const store = new WorkspaceStore() + const workspace: Workspace = { version: 2, activeProjectId: 'launch-project', projects: [{ + id: 'launch-project', name: 'Launch', cwd, color: '#0a84ff', viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [{ id: 'term-settings-saved', kind: 'terminal', title: 'Saved', cwd: sub, agentId: 'claude', + color: '#0a84ff', group: null, position: { x: 0, y: 0 }, size: { width: 80, height: 24 } }] + }] } + await store.save(workspace) + const doc: ProjectSettingsFileV1 = { version: 1, rev: 1, savedAt: 'fixture', + agents: { env: { A72_PROJECT_ENV: 'trusted-project' } }, terminal: { shell: '/bin/sh' } } + fs.writeFileSync(path.join(cwd, '.nodeterm', 'settings.json'), JSON.stringify(doc)) + const trust = new ProjectTrustStore() + if (process.env.FIXTURE_LAUNCH_TRUSTED === '1') { + for (const family of ['agents', 'shell'] as const) { + const content = projectTrustContent(family, doc) + if (content === null) throw new Error('fixture trust family has no content') + await trust.record(localTrustKey(cwd), family, hashTrustContent(content), 'fixture') + } + } + const pty = new PtyManager() + // Pin the ordinary plain-shell leg even if the developer has a built session-host bundle. + pty.init(() => ({ ...DEFAULT_SETTINGS, tmuxEnabled: false })) + pty.setProjectSpawnOverrides(makeProjectSpawnOverrides({ + readSettings: (id) => store.readProjectSettings(id), + targetInfo: (id) => store.projectTargetInfo(id), trust, + requestTrust: (projectId, family) => emit({ event: 'launch-trust', projectId, family }) + })) + const keys = genKeyPair() + const port = await startBroker() + connectHostSession({ + url: `ws://127.0.0.1:${port}`, token: 'host-room', ourKeys: keys, pty, + getLatestCanvas: () => null, subscribeCanvas: () => () => {}, applyMutation: () => {}, + newSessions: createHostNewSessions({ + projectTargetInfo: (id) => store.projectTargetInfo(id), claudeAccounts: () => [], + persistedCanvases: () => store.persistedCanvases() + }), + onPeerReady: (session) => { session.approve() }, onClose: () => {} + }) + emit({ ready: true, relayUrl: `ws://127.0.0.1:${port}`, clientToken: 'client-room', + hostPublicKeyB64: publicKeyToB64(keys.publicKey), projectCwd: cwd, savedCwd: sub }) +} +void main().catch((error) => { emit({ event: 'fatal', message: String(error) }); process.exit(1) }) diff --git a/android/protocol/src/test/interop/relay-advertisement-fixture.ts b/android/protocol/src/test/interop/relay-advertisement-fixture.ts new file mode 100644 index 0000000000..c13c3cc413 --- /dev/null +++ b/android/protocol/src/test/interop/relay-advertisement-fixture.ts @@ -0,0 +1,55 @@ +// Actual advertisement writer/remover with a synthetic OS-home boundary, not a standing relay host. +// HOME is inherited unchanged. Only initialization sees the private home; the original homedir +// function is restored before either production operation runs. No credentials, mint or relay socket. +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import readline from 'node:readline' +import type { RelayAdvertisement } from '../../../../../src/main/remote/relay-advertise' + +const emit = (value: unknown): void => { process.stdout.write(JSON.stringify(value) + '\n') } + +async function main(): Promise { + const home = process.env.FIXTURE_HOME + const nonce = process.env.FIXTURE_NONCE + const realHomedir = os.homedir + const originalHome = realHomedir() + if (process.argv[2] !== 'relay-advertisement' || !home || !path.isAbsolute(home) || + home === originalHome || !nonce || !/^[a-f0-9]{32}$/.test(nonce)) throw new Error('Private fixture home missing') + const stat = await fs.lstat(home) + const markerPath = path.join(home, '.relay-advertisement-fixture') + const marker = await fs.lstat(markerPath) + if (!stat.isDirectory() || stat.isSymbolicLink() || await fs.realpath(home) !== home || + stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700 || + !marker.isFile() || marker.isSymbolicLink() || marker.uid !== stat.uid || + (marker.mode & 0o777) !== 0o600 || marker.size !== 32 || await fs.readFile(markerPath, 'utf8') !== nonce) { + throw new Error('Private fixture ownership changed') + } + // relay-advertise's FILE is initialized once. Adapt only that explicit OS fact in this fresh child. + let producer: typeof import('../../../../../src/main/remote/relay-advertise') + try { + os.homedir = () => home + producer = await import('../../../../../src/main/remote/relay-advertise') + } finally { + os.homedir = realHomedir + } + if (os.homedir !== realHomedir || os.homedir() !== originalHome) throw new Error('OS home adapter not restored') + emit({ ready: true, homeRestored: true, boundary: 'actual writer/remover; synthetic OS home; no relay host or adoption' }) + const lines = readline.createInterface({ input: process.stdin }) + for await (const line of lines) { + if (line === 'remove') { + await producer.removeRelayAdvertisement() + emit({ event: 'removed' }) + } else if (line.startsWith('write ') && Buffer.byteLength(line, 'utf8') <= 4096) { + const value: RelayAdvertisement = JSON.parse(line.slice(6)) + if (value.v !== 1 || ['hostId', 'hostPublicKeyB64', 'relayEndpoint', 'hostDeviceId'] + .some(key => typeof value[key as keyof RelayAdvertisement] !== 'string')) throw new Error('Public fixture fields missing') + await producer.writeRelayAdvertisement(value) + emit({ event: 'written' }) + } else { + throw new Error('Unknown fixture command') + } + } +} + +void main().catch(error => { emit({ event: 'fatal', message: String(error) }); process.exitCode = 1 }) diff --git a/android/protocol/src/test/interop/server-profile-fixture.ts b/android/protocol/src/test/interop/server-profile-fixture.ts new file mode 100644 index 0000000000..f697c38e6b --- /dev/null +++ b/android/protocol/src/test/interop/server-profile-fixture.ts @@ -0,0 +1,105 @@ +// Actual Server config/platform/store/mirror/actions producer. Full startServer is NOT invoked: +// no hook listener, installed hooks, CLI/account probing or user services. The plain-shell plan +// and create/verify boundary below are fixture scaffolding around real private tmux, not PtyManager. +import fs from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' +import readline from 'node:readline' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { resolveConfig } from '../../../../../src/server/config' +import { ServerPlatform } from '../../../../../src/server/platform-server' +import { initPlatform } from '../../../../../src/core/platform' +import { WorkspaceStore } from '../../../../../src/core/workspace-store' +import { initAgentStatusMirror, recordAgentEvent, flush, setMirrorServerProvider } from '../../../../../src/core/agent-status-mirror' +import { ManagedTerminals, type ManagedTerminalPlan } from '../../../../../src/core/managed-terminals' +import { SshActionsService } from '../../../../../src/core/ssh-actions' +import { MANAGED_PANE_FORMAT, parseManagedPane, readManagedProcessBirth, sameManagedPane } from '../../../../../src/core/managed-pane' +import type { ManagedPaneReceipt } from '../../../../../src/shared/managed-terminal' +import type { PtyCreateOptions, Workspace } from '../../../../../src/shared/types' + +const emit = (value: unknown): void => { process.stdout.write(JSON.stringify(value) + '\n') } +const run = promisify(execFile) +async function main(): Promise { + if (process.argv[2] !== 'server-profile' || !process.env.FIXTURE_USERDATA || !process.env.FIXTURE_HOME || + !process.env.FIXTURE_TMUX_DIR || !process.env.FIXTURE_TMUX_BIN) throw new Error('Server fixture inputs missing') + const home = path.resolve(process.env.FIXTURE_HOME), userData = path.resolve(process.env.FIXTURE_USERDATA) + const tmuxDirectory = path.resolve(process.env.FIXTURE_TMUX_DIR) + if (os.homedir() !== home || !userData.startsWith(home + path.sep) || !tmuxDirectory.startsWith(path.dirname(home) + path.sep) || + tmuxDirectory === home || tmuxDirectory === userData) throw new Error('Server fixture scope changed') + const tmuxStat = await fs.lstat(tmuxDirectory) + if (!tmuxStat.isDirectory() || tmuxStat.isSymbolicLink() || tmuxStat.uid !== process.getuid?.()) throw new Error('Private tmux directory unavailable') + const tmuxBin = await fs.realpath(process.env.FIXTURE_TMUX_BIN) + const tmuxEnv = { PATH: '/usr/bin:/bin', TMUX_TMPDIR: tmuxDirectory, LANG: process.platform === 'darwin' ? 'en_US.UTF-8' : 'C.UTF-8' } + const tmux = async (args: string[]): Promise => (await run(tmuxBin, ['-L', 'node-terminal', ...args], { env: tmuxEnv, timeout: 5000 })).stdout + const config = { ...resolveConfig({ NODETERM_HEADLESS: '1' }, ['--data-dir', userData]), installHooks: false } + if (config.dataDir !== userData || !config.headless || config.installHooks !== false) throw new Error('Actual Server config disagreed') + await fs.mkdir(userData, { recursive: true, mode: 0o700 }) + const defaultData = path.join(home, '.config/node-terminal'), defaultCwd = path.join(home, 'server-default-project') + const cwd = path.join(home, 'server-custom-project') + await fs.mkdir(defaultCwd, { mode: 0o700 }); await fs.mkdir(cwd, { mode: 0o700 }) + const workspace = (id: string, name: string, projectCwd: string, nodeId: string): Workspace => ({ + version: 2, activeProjectId: id, projects: [{ id, name, cwd: projectCwd, color: '#0a84ff', + viewport: { x: 0, y: 0, zoom: 1 }, nodes: [{ id: nodeId, kind: 'terminal', title: name, + color: '#0a84ff', group: null, cwd: projectCwd, position: { x: 0, y: 0 }, size: { width: 640, height: 440 } }] }] + }) + // Both profiles are genuine WorkspaceStore publications; no hand-written workspace/project blob. + await fs.mkdir(defaultData, { recursive: true, mode: 0o700 }) + initPlatform(new ServerPlatform({ userDataDir: defaultData, appVersion: 'fixture' })) + await new WorkspaceStore().save(workspace('server-default', 'Default desktop', defaultCwd, 'term-server-default')) + const platform = new ServerPlatform({ userDataDir: config.dataDir, appVersion: 'fixture' }); initPlatform(platform) + const store = new WorkspaceStore(); await store.save(workspace('server-custom', 'Custom server', cwd, 'term-server-seed')) + await store.load({ sideline: false }) + initAgentStatusMirror() + setMirrorServerProvider(() => ({ version: 'fixture-custom', commit: 'custom-profile' })) + recordAgentEvent({ nodeId: 'term-server-seed', agentId: 'claude', kind: 'state', state: 'done' }) + await flush() + const created: ManagedPaneReceipt[] = [] + const read = async (r: Pick): Promise => { + const raw = await tmux(['list-panes', '-s', '-t', '=' + r.session, '-F', MANAGED_PANE_FORMAT]) + const pane = parseManagedPane(raw, r) + if (!pane) throw new Error('Owned fixture pane identity changed') + return { version: 1, ...r, socket: 'node-terminal', ...pane, paneBirth: await readManagedProcessBirth(pane.panePid) } + } + const coordinator = new ManagedTerminals(userData, store, { + supported: () => true, + plan: async (request, nodeId): Promise => { + const target = store.projectTargetInfo(request.projectId) + if (request.kind !== 'shell' || request.projectId !== 'server-custom' || target?.cwd !== cwd || target.ssh) + throw new Error('Only the fixture owned plain-shell project is supported') + const node = { id: nodeId, kind: 'terminal' as const, title: request.title ?? 'Managed shell', cwd, + shell: '/bin/sh', color: '#0a84ff', group: null, position: { x: 0, y: 0 }, size: { width: 640, height: 440 } } + return { node, options: { persistKey: nodeId, ownerProjectId: request.projectId, cwd, shell: '/bin/sh', cols: request.cols, rows: request.rows } } + }, + revalidate: async (request, plan) => request.projectId === 'server-custom' && store.projectTargetInfo(request.projectId)?.cwd === cwd && plan.node.cwd === cwd, + create: async (options: PtyCreateOptions, creationId, current) => { + if (!current() || options.ownerProjectId !== 'server-custom' || options.cwd !== cwd || !options.persistKey) throw new Error('Fixture create admission changed') + const session = 'nt-' + options.persistKey + await tmux(['-f', '/dev/null', 'new-session', '-d', '-s', session, '-c', cwd, '-x', String(options.cols), '-y', String(options.rows), + '-e', 'NODETERM_MANAGED_CREATION_ID=' + creationId, '/usr/bin/env', 'ENV=', 'BASH_ENV=', 'INPUTRC=/dev/null', '/bin/sh']) + const receipt = await read({ creationId, session, nodeId: options.persistKey, projectId: 'server-custom' }) + created.push(receipt); return receipt + }, + verify: async (r, current) => current() && sameManagedPane(r, await read(r)), + deliver: async () => { throw new Error('The plain-shell fixture must never deliver an agent command') } + }) + const service = new SshActionsService(userData, store, undefined, false, coordinator) + await service.start() + emit({ ready: true, userData, cwd, projectId: 'server-custom', nodeId: 'term-server-seed', defaultData, + boundary: 'actual Server config/platform/store/mirror/file service; fixture plain-shell plan/native tmux boundary; no full boot/hooks' }) + const lines = readline.createInterface({ input: process.stdin }) + try { + for await (const line of lines) { + if (line === 'snapshot') emit({ event: 'snapshot', created, + file: JSON.parse(await fs.readFile(path.join(cwd, '.nodeterm/project.json'), 'utf8')), + defaultFile: JSON.parse(await fs.readFile(path.join(defaultCwd, '.nodeterm/project.json'), 'utf8')) }) + else if (line === 'stop') { await service.stop(); emit({ event: 'stopped' }) } + else throw new Error('Unknown Server fixture driver command') + } + } finally { + await service.stop() + // Exact recorded sessions only. The surrounding SSH fixture owns its socket/server teardown. + for (const pane of created) await tmux(['kill-session', '-t', '=' + pane.session]).catch(() => {}) + } +} +void main().catch(error => { emit({ event: 'fatal', message: String(error) }); process.exit(1) }) diff --git a/android/protocol/src/test/interop/server-profile-os.ts b/android/protocol/src/test/interop/server-profile-os.ts new file mode 100644 index 0000000000..6b4ce7ebbb --- /dev/null +++ b/android/protocol/src/test/interop/server-profile-os.ts @@ -0,0 +1,10 @@ +// Only server-profile-fixture resolves OS home reads here. HOME itself is unchanged. +import * as real from 'node:os' +import path from 'node:path' +export * from 'node:os' +export function homedir(): string { + const home = process.env.FIXTURE_HOME + if (!home || !path.isAbsolute(home)) throw new Error('Server profile fixture needs its private home') + return home +} +export default { ...real, homedir } diff --git a/android/protocol/src/test/interop/ssh-actions-fixture.ts b/android/protocol/src/test/interop/ssh-actions-fixture.ts new file mode 100644 index 0000000000..efcd5264d8 --- /dev/null +++ b/android/protocol/src/test/interop/ssh-actions-fixture.ts @@ -0,0 +1,53 @@ +// Real SSH companion producer, WorkspaceStore save chain and filesystem transport. No sockets, +// Electron, credential/token files, user profile, project launch or native PTY execution. +import { promises as fs } from 'node:fs' +import path from 'node:path' +import readline from 'node:readline' +import { initPlatform } from '../../../../../src/core/platform' +import { fakePlatform } from '../../../../../src/core/platform-fake' +import { WorkspaceStore } from '../../../../../src/core/workspace-store' +import { SshActionsService } from '../../../../../src/core/ssh-actions' +import type { Workspace } from '../../../../../src/shared/types' + +const emit = (value: unknown): void => { process.stdout.write(JSON.stringify(value) + '\n') } +async function main(): Promise { + if (process.argv[2] !== 'ssh-actions' || !process.env.FIXTURE_USERDATA || !process.env.FIXTURE_HOME || process.env.HOME !== process.env.FIXTURE_HOME) throw new Error('SSH actions fixture needs a private home and selected profile') + const userData = path.resolve(process.env.FIXTURE_USERDATA) + const home = path.resolve(process.env.FIXTURE_HOME) + if (!userData.startsWith(home + path.sep)) throw new Error('SSH actions fixture profile must be inside its private home') + await fs.mkdir(userData, { recursive: true, mode: 0o700 }) + const fake = fakePlatform({ userDataDir: userData }); initPlatform(fake) + const cwd = path.join(home, 'ssh-actions-project') + await fs.mkdir(cwd, { mode: 0o700 }) + const store = new WorkspaceStore() + const workspace: Workspace = { version: 2, activeProjectId: 'ssh-actions-project', projects: [{ + id: 'ssh-actions-project', name: 'SSH Board', cwd, color: '#0a84ff', viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [{ id: 'term-ssh-actions', kind: 'terminal', title: 'Synthetic', color: '#0a84ff', group: null, + position: { x: 0, y: 0 }, size: { width: 80, height: 24 } }] + }] } + await store.save(workspace) + const nudges: Array<{ method: string; nodeId: string; title?: string }> = [] + const deliver = (method: string, nodeId: string, title?: string): boolean => { + const nudge = { method, nodeId, ...(title !== undefined ? { title } : {}) }; nudges.push(nudge) + emit({ event: 'node-action', ...nudge }); return true + } + let service = new SshActionsService(userData, store, { + wake: (id) => deliver('wake', id), refresh: (id) => deliver('refresh', id), rename: (id, title) => deliver('rename', id, title) + }) + await service.start() + const ready = (): void => emit({ ready: true, userData, cwd, projectId: 'ssh-actions-project', nodeId: 'term-ssh-actions', advertisement: path.join(service.root, 'advertisement.json'), instanceDir: service.directory }) + ready() + const lines = readline.createInterface({ input: process.stdin }) + // The test driver controls reads/restart only; it never bypasses the production request handler. + for await (const line of lines) { + if (line === 'snapshot') emit({ event: 'snapshot', file: JSON.parse(await fs.readFile(path.join(cwd, '.nodeterm/project.json'), 'utf8')), broadcasts: fake.sent, nudges }) + else if (line === 'restart') { + await service.stop() + service = new SshActionsService(userData, store, { wake: (id) => deliver('wake', id), refresh: (id) => deliver('refresh', id), rename: (id, title) => deliver('rename', id, title) }) + await service.start(); emit({ event: 'restarted', advertisement: path.join(service.root, 'advertisement.json'), instanceDir: service.directory }) + } else if (line === 'stop') { await service.stop(); emit({ event: 'stopped' }) } + else throw new Error('Unknown fixture driver command') + } + await service.stop() +} +void main().catch((error) => { emit({ event: 'fatal', message: String(error) }); process.exitCode = 1 }) diff --git a/android/protocol/src/test/interop/terminal-closed-view-driver.cjs b/android/protocol/src/test/interop/terminal-closed-view-driver.cjs new file mode 100644 index 0000000000..b1f5b4fce4 --- /dev/null +++ b/android/protocol/src/test/interop/terminal-closed-view-driver.cjs @@ -0,0 +1,257 @@ +// Actual shipped xterm5.5 and terminal page; jsdom supplies layout/canvas stubs only. +'use strict' +const fs = require('node:fs'), path = require('node:path'), crypto = require('node:crypto'), assert = require('node:assert/strict') +const { JSDOM } = require('jsdom') +const scriptPath = process.argv[2] +assert.ok(scriptPath, 'pass the actual terminal.js asset as argv[2]') +const assets = process.argv[3] || path.dirname(scriptPath) +const script = fs.readFileSync(scriptPath, 'utf8') +const digestBytes = bytes => crypto.createHash('sha256').update(bytes).digest('hex') +const digest = file => digestBytes(fs.readFileSync(file)) +const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'terminal-closed-view-raw.json'), 'utf8')) +assert.equal(fixture.schema, 1) +const EXPECTED_RAW = [ + ['immediate', 'b84c4363df8ad30e0d409866ed9b03ada01464c1b2dc58e2aa3685594c31eeb7', 1542, 1387, 0], + ['delayed', '1e5a93d7e6bac2546005f9ecd937584662e4d85d75de9d6e02aa1606ee48aff4', 1538, 1383, 150] +] +assert.equal(fixture.records.length, EXPECTED_RAW.length) +const rawRecords = fixture.records.map((record, i) => { + const raw = Buffer.from(record.base64, 'base64') + assert.equal(raw.toString('base64'), record.base64, 'canonical exact raw bytes') + assert.deepEqual([record.variant, record.rawSha256, record.bytes, record.beforeOffset, record.requestedFinalWriteDelayMs], EXPECTED_RAW[i]) + assert.equal(digestBytes(raw), record.rawSha256, 'original native raw fixture hash') + assert.equal(raw.length, record.bytes) + assert.equal(record.cols, 49); assert.equal(record.rows, 48) + return {...record, raw} +}) +// Capture timing is provenance only: replay deliberately uses coalesced or one-byte writes. +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) +const cases = [] +const EXPECTED_CASES = Object.freeze([ + "immediate real suffix coalesced", + "immediate real suffix byte splits", + "delayed real suffix coalesced", + "delayed real suffix byte splits", + "relay/native opt-out uses unchanged live parser", + "queued EOF cancelled by begin", + "queued EOF cancelled by pagehide", + "queued EOF cancelled by suspend", + "live ED2 repaint and later teardown retain current pane", + "latest blank ED2 replaces an older populated capture", + "drop without outer mode restore keeps active pane unchanged", + "Unicode wrap Copy and OSC8/plain URL touch are captured without input", + "closed user-origin event cannot turn next viewer DA into typed input", + "closed display invalidates on paint", + "closed display invalidates on reset", + "settled closed rows and Copy survive refit", + "settled closed rows and Copy survive setFontSize", + "settled closed rows and Copy survive resize", + "geometry invalidates only a pending EOF promotion", + "old closed link touch cannot activate a successor snapshot", + "same-view JS submit keeps separated Enter", + "same-view delayed Enter survives refit font and resize barriers", + "delayed JS Enter cannot cross begin", + "delayed JS Enter cannot cross retire", + "settled snapshot redraw uses changed layout and font for its owned link touch", + "stationary closed touch on wrapped plain URL offers the exact complete URL", + "generated tmux TUI repaint keeps live alternate modes and reports before EOF" +]) +async function environment(cols = 49, rows = 48) { + const style = fs.readFileSync(path.join(assets, 'index.html'), 'utf8').match(/
', {url:'https://terminal.test/',runScripts:'outside-only',pretendToBeVisual:true}) + const win = dom.window + win.TextDecoder = TextDecoder; win.TextEncoder = TextEncoder + win.matchMedia = () => ({matches:false,addListener(){},removeListener(){}}) + win.HTMLCanvasElement.prototype.getContext = () => ({measureText:() => ({width:10,actualBoundingBoxAscent:16,actualBoundingBoxDescent:4}),fillRect(){},clearRect(){},getImageData:() => ({data:new Uint8Array([0,0,0,255])}),createLinearGradient:() => ({addColorStop(){}})}) + let layout={left:4,top:2,width:520,height:900,clientWidth:526,clientHeight:906} + Object.defineProperties(win.HTMLElement.prototype,{clientWidth:{get(){return layout.clientWidth}},clientHeight:{get(){return layout.clientHeight}},offsetWidth:{get(){return (this.textContent||'').length*10}},offsetHeight:{get(){return 20}}}) + win.HTMLElement.prototype.getBoundingClientRect = () => ({left:layout.left,top:layout.top,width:layout.width,height:layout.height,right:layout.left+layout.width,bottom:layout.top+layout.height}) + let term, ready = false, copied + const inputs = [], reports = [], opened = [], scrolls = [] + win.NodetermBridge = {copyLimit:() => 400000,onResize(){},onInput(d){inputs.push(d)},onReport(d){reports.push(d)},onScroll(...a){scrolls.push(a)},onScrollStop(){},onScrollView(...a){scrolls.push(a)},onHistoryClose(){},onCopy(){},onCopyTooLarge(){},onCopySheet(raw){copied=JSON.parse(raw)},openUrl(uri){opened.push(uri)},onReady(){ready=true}} + win.eval(fs.readFileSync(path.join(assets,'xterm.js'),'utf8')) + const Original = win.Terminal + win.Terminal = class extends Original {constructor(options){super(options);term=this}} + win.eval(fs.readFileSync(path.join(assets,'addon-fit.js'),'utf8')) + win.eval(script) + for (let n=0;!ready&&n<100;n++) await pause(5) + assert.ok(ready,'actual page ready') + term.resize(cols, rows) + const drain = () => new Promise(resolve => term.write('',resolve)) + const write = text => {win.nt.write(Buffer.from(text).toString('base64'))} + const parsed = async text => {write(text);await drain()} + const begin = async (token='one',known=true) => {win.nt.beginViewer(token,known);await drain()} + const copy = () => {win.nt.copySheet();return copied} + const visible = () => !win.document.getElementById('closed-view').hidden + const screen = () => Array.from({length:term.buffer.active.length},(_,r)=>term.buffer.active.getLine(r).translateToString(true)) + function touch(type,x,y,moved=false) { + const e = new win.Event(type,{bubbles:true,cancelable:true}) + const t = {clientX:x,clientY:y,identifier:1} + Object.defineProperties(e,{touches:{value:(type==='touchend'||type==='touchcancel')?[]:[t]},changedTouches:{value:[t]}}) + win.document.getElementById('closed-view').dispatchEvent(e) + return e.defaultPrevented + } + const setLayout = next => {layout={...layout,...next};const host=win.document.getElementById('term');host.style.width=layout.clientWidth+'px';host.style.height=layout.clientHeight+'px'} + return {win,term,inputs,reports,opened,scrolls,drain,write,parsed,begin,copy,visible,screen,touch,setLayout,close:()=>dom.window.close()} +} +async function test(name,fn) { + const started=performance.now() + try {const detail=await fn();cases.push({name,passed:true,elapsedMs:+(performance.now()-started).toFixed(3),detail})} + catch(error){cases.push({name,passed:false,elapsedMs:+(performance.now()-started).toFixed(3),errorType:error.name,error:String(error.message).slice(0,2000),stack:String(error.stack).slice(0,4000)})} +} +const ALT='\x1b[?1049h', CLEAR='\x1b[2J', CURSOR_HOME='\x1b[H', END=CLEAR+'\x1b[?1049l[exited]\r\n' +async function closedSimple(e,text='CURRENT_VISIBLE') {await e.begin();await e.parsed(ALT+CURSOR_HOME+text);e.write(END);e.win.nt.endViewer('one');await e.drain();assert.ok(e.visible(),'closed pane promoted after parser drain')} +async function rawCase(record, split, known=true, cancel=null) { + const e=await environment(record.cols,record.rows) + try { + const raw=record.raw + await e.begin('one',known) + await e.parsed(raw.subarray(0,record.beforeOffset)) + assert.ok(e.screen().some(row=>row.includes('A133_BEFORE_EXIT')),'calibrated real prefix') + const priorRows=Array.from({length:12},(_,n)=>'A133_ROW_'+String(n).padStart(2,'0')+' Ω🧭') + for(const row of priorRows) assert.ok(e.screen().some(line=>line.includes(row)), 'original prefix retains '+row) + const suffix=raw.subarray(record.beforeOffset) + if(split) for(const b of suffix) e.write(Uint8Array.of(b)); else e.write(suffix) + // Do not await the pending bytes: end must supply its own parse barrier. + e.win.nt.endViewer('one') + if(cancel==='begin') e.win.nt.beginViewer('two',true) + if(cancel==='pagehide') e.win.dispatchEvent(new e.win.Event('pagehide')) + if(cancel==='suspend') e.win.nt.suspendScroll() + await e.drain() + assert.equal(e.term.buffer.active.type,'normal','real tmux mode restore still parsed') + assert.ok(e.screen().some(row=>row.includes('[exited]')),'live parser retains real trailer') + assert.equal(e.visible(),known&&!cancel,'explicit current SSH EOF only') + const copied=e.copy() + if(known&&!cancel) { + const displayed=e.win.document.getElementById('closed-view').textContent + for(const row of [...priorRows,'A133_FINAL_TAIL']) { + assert.ok(copied.lines.some(line=>line.includes(row)), 'closed Copy retains '+row) + assert.ok(displayed.includes(row), 'closed display retains '+row) + } + e.win.nt.closeScrollView();assert.ok(e.visible(),'action retirement keeps closed display') + } else assert.ok(!copied.lines.some(line=>line.includes('A133_FINAL_TAIL')),'retired/opt-out capture not adopted') + return {rawSha256:digestBytes(raw),splitAtEveryByte:split,knownSshTmux:known,cancel,liveType:e.term.buffer.active.type,copy:copied} + } finally {e.close()} +} +async function main() { + for(const record of rawRecords) for(const split of [false,true]) await test(record.variant+' real suffix '+(split?'byte splits':'coalesced'),()=>rawCase(record,split)) + await test('relay/native opt-out uses unchanged live parser',()=>rawCase(rawRecords[0],false,false)) + for(const cancel of ['begin','pagehide','suspend']) await test('queued EOF cancelled by '+cancel,()=>rawCase(rawRecords[0],false,true,cancel)) + await test('live ED2 repaint and later teardown retain current pane',async()=>{ + const e=await environment();try {await e.begin();await e.parsed(ALT+CURSOR_HOME+'OLD');await e.parsed(CLEAR+CURSOR_HOME+'NEW');assert.equal(e.visible(),false);assert.ok(e.screen()[0].includes('NEW'));e.write(END);e.win.nt.endViewer('one');await e.drain();assert.ok(e.visible());assert.ok(e.copy().lines[0].includes('NEW'));assert.ok(!e.copy().lines.join('').includes('OLD'));return {copy:e.copy()}} finally {e.close()} + }) + await test('latest blank ED2 replaces an older populated capture',async()=>{ + const e=await environment();try {await e.begin();await e.parsed(ALT+CURSOR_HOME+'STALE');await e.parsed(CLEAR);e.write(END);e.win.nt.endViewer('one');await e.drain();assert.ok(e.visible());assert.deepEqual(e.copy().lines,[]);return {copy:e.copy()}} finally {e.close()} + }) + await test('drop without outer mode restore keeps active pane unchanged',async()=>{ + const e=await environment();try {await e.begin();await e.parsed(ALT+CURSOR_HOME+'LIVE_PANE');e.win.nt.endViewer('one');await e.drain();assert.equal(e.visible(),false);assert.equal(e.term.buffer.active.type,'alternate');assert.ok(e.copy().lines[0].includes('LIVE_PANE'));return {copy:e.copy()}} finally {e.close()} + }) + await test('Unicode wrap Copy and OSC8/plain URL touch are captured without input',async()=>{ + const e=await environment(40,12);try { + await e.begin();const uri='https://example.test/osc8';const long='https://example.test/long/'+ 'segment'.repeat(8) + await e.parsed(ALT+CURSOR_HOME+'Ω 🧭 日本語\r\n\x1b]8;;'+uri+'\x1b\\LABEL\x1b]8;;\x1b\\\r\n'+long) + const before=e.copy();assert.ok(before.links.includes(uri));assert.ok(before.links.includes(long));assert.ok(e.term.buffer.active.getLine(3).isWrapped) + e.write(END);e.win.nt.endViewer('one');await e.drain();assert.ok(e.visible());assert.deepEqual(e.copy(),before) + const x=4+(0.5*520/40), y=2+(1.5*900/12) + e.touch('touchstart',x,y);assert.ok(e.touch('touchend',x,y));assert.deepEqual(e.opened,[uri]) + e.touch('touchstart',x,y);e.touch('touchmove',x,y+50);e.touch('touchend',x,y+50);assert.equal(e.opened.length,1,'moved touch inert') + e.win.nt.raw(Buffer.from('never').toString('base64'));e.win.nt.key('enter');e.win.nt.submit(Buffer.from('never').toString('base64'),true);e.term.paste('ignored');e.win.nt.resumeScroll();await pause(180) + assert.deepEqual(e.inputs,[]);assert.deepEqual(e.scrolls,[]);assert.ok(e.visible());return {beforeCopy:before,closedCopy:e.copy(),offered:e.opened,inputCount:e.inputs.length} + } finally {e.close()} + }) + await test('closed user-origin event cannot turn next viewer DA into typed input',async()=>{ + const e=await environment();try {await closedSimple(e);e.term.paste('ignored');await e.begin('two');e.inputs.length=0;e.reports.length=0;await e.parsed('\x1b[c');assert.deepEqual(e.inputs,[]);assert.ok(e.reports.some(d=>d.endsWith('c')));return {inputs:e.inputs,reports:e.reports}} finally {e.close()} + }) + for(const action of ['paint','reset']) await test('closed display invalidates on '+action,async()=>{ + const e=await environment();try {await closedSimple(e);if(action==='paint')e.win.nt.paint(Buffer.from('NEW_SNAPSHOT').toString('base64'));else if(action==='setFontSize')e.win.nt.setFontSize(19);else e.win.nt[action]();await e.drain();assert.equal(e.visible(),false);assert.ok(!e.copy().lines.join('').includes('CURRENT_VISIBLE'));return {action,copy:e.copy()}} finally {e.close()} + }) + for(const action of ['refit','setFontSize','resize']) await test('settled closed rows and Copy survive '+action,async()=>{ + const e=await environment();try {await closedSimple(e);const before=e.copy();if(action==='resize')e.win.dispatchEvent(new e.win.Event('resize'));else if(action==='setFontSize')e.win.nt.setFontSize(19);else e.win.nt.refit();await e.drain();assert.ok(e.visible());assert.deepEqual(e.copy(),before);assert.deepEqual(e.inputs,[]);return {action,copy:e.copy()}} finally {e.close()} + }) + await test('geometry invalidates only a pending EOF promotion',async()=>{ + const e=await environment();try {await e.begin();await e.parsed(ALT+CURSOR_HOME+'PENDING');e.write(END);e.win.nt.endViewer('one');e.win.nt.refit();await e.drain();assert.equal(e.visible(),false);return {copy:e.copy()}} finally {e.close()} + }) + await test('old closed link touch cannot activate a successor snapshot',async()=>{ + const e=await environment();try { + const label=uri=>'\x1b]8;;'+uri+'\x1b\\LABEL\x1b]8;;\x1b\\' + await closedSimple(e,label('https://example.test/old')) + const x=4+520/49/2,y=2+900/48/2;e.touch('touchstart',x,y) + await e.begin('two');await e.parsed(ALT+CURSOR_HOME+label('https://example.test/new'));e.write(END);e.win.nt.endViewer('two');await e.drain();assert.ok(e.visible()) + e.touch('touchend',x,y);assert.deepEqual(e.opened,[]);return {opened:e.opened} + } finally {e.close()} + }) + await test('same-view JS submit keeps separated Enter',async()=>{ + const e=await environment();try {await e.begin();e.win.nt.submit(Buffer.from('TEXT').toString('base64'),true);assert.deepEqual(e.inputs,['TEXT']);await pause(180);assert.deepEqual(e.inputs,['TEXT','\r']);return {inputs:e.inputs}} finally {e.close()} + }) + await test('same-view delayed Enter survives refit font and resize barriers',async()=>{ + const e=await environment();try { + await e.begin();e.win.nt.submit(Buffer.from('TEXT').toString('base64'),true) + e.win.nt.refit();e.win.nt.setFontSize(19);e.win.dispatchEvent(new e.win.Event('resize')) + await e.drain();await pause(180) + assert.deepEqual(e.inputs,['TEXT','\r']);assert.deepEqual(e.scrolls,[]) + return {inputs:e.inputs,sameViewerLayoutChanges:true} + } finally {e.close()} + }) + for(const action of ['begin','retire']) await test('delayed JS Enter cannot cross '+action,async()=>{ + const e=await environment();try {await e.begin();e.win.nt.submit(Buffer.from('TEXT').toString('base64'),true);if(action==='begin')e.win.nt.beginViewer('two',true);else e.win.nt.retireViewer();await pause(180);assert.deepEqual(e.inputs,['TEXT']);return {inputs:e.inputs}} finally {e.close()} + }) + await test('settled snapshot redraw uses changed layout and font for its owned link touch',async()=>{ + const e=await environment(40,12);try { + const uri='https://example.test/changed-layout' + await closedSimple(e,'Ω 🧭 ROW_ZERO\r\n\x1b]8;;'+uri+'\x1b\\LABEL\x1b]8;;\x1b\\') + const before=e.copy(),oldGrid={cols:e.term.cols,rows:e.term.rows} + e.setLayout({left:104,top:202,width:300,height:240,clientWidth:306,clientHeight:246}) + e.win.nt.setFontSize(19);e.win.dispatchEvent(new e.win.Event('resize'));await e.drain() + assert.ok(e.visible());assert.deepEqual(e.copy(),before) + const layer=e.win.document.getElementById('closed-view') + assert.ok(layer.textContent.includes('Ω 🧭 ROW_ZERO'));assert.ok(layer.textContent.includes('LABEL')) + assert.equal(layer.children[1].style.fontSize,'19px') + const screen=e.term.element.querySelector('.xterm-screen').getBoundingClientRect() + assert.equal(screen.left,104);assert.equal(screen.top,202);assert.equal(screen.width,300);assert.equal(screen.height,240) + assert.ok(e.term.cols!==oldGrid.cols||e.term.rows!==oldGrid.rows,'actual fit admits the changed grid') + assert.equal(parseFloat(layer.children[1].style.lineHeight),screen.height/e.term.rows) + const x=screen.left+0.5*screen.width/e.term.cols,y=screen.top+1.5*screen.height/e.term.rows + e.touch('touchstart',x,y);assert.ok(e.touch('touchend',x,y));assert.deepEqual(e.opened,[uri]) + assert.deepEqual(e.inputs,[]);assert.deepEqual(e.scrolls,[]) + return {beforeGrid:oldGrid,currentGrid:{cols:e.term.cols,rows:e.term.rows},screen,touch:{x,y},offered:e.opened,copy:e.copy(),componentLayoutOnly:true} + } finally {e.close()} + }) + await test('stationary closed touch on wrapped plain URL offers the exact complete URL',async()=>{ + const e=await environment(30,10);try { + const uri='https://example.test/plain/'+ 'segment'.repeat(8) + await closedSimple(e,'HEADER\r\n'+uri) + assert.ok(e.copy().links.includes(uri));assert.ok(e.copy().lines.some(line=>line.includes(uri))) + const x=4+5.5*520/30,y=2+2.5*900/10 + e.touch('touchstart',x,y);assert.ok(e.touch('touchend',x,y));assert.deepEqual(e.opened,[uri]) + assert.deepEqual(e.inputs,[]);assert.deepEqual(e.scrolls,[]) + return {completeUrl:uri,touchedCapturedPhysicalRow:2,touchedCapturedColumn:5,offered:e.opened,copy:e.copy()} + } finally {e.close()} + }) + await test('generated tmux TUI repaint keeps live alternate modes and reports before EOF',async()=>{ + const e=await environment();try { + await e.begin() + // Generated outer-tmux painter frames, not an actual inner-TUI or SSH runtime recording. + await e.parsed(ALT+'\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?1h'+CLEAR+CURSOR_HOME+'TUI_FRAME\r\n> choice one\x1b[5n') + assert.equal(e.term.buffer.active.type,'alternate');assert.equal(e.term.modes.mouseTrackingMode,'vt200') + assert.equal(e.term.modes.applicationCursorKeysMode,true);assert.equal(e.term.modes.bracketedPasteMode,true) + assert.ok(e.reports.includes('\x1b[0n'));assert.equal(e.visible(),false);assert.ok(e.copy().lines[0].includes('TUI_FRAME')) + e.reports.length=0 + await e.parsed(CLEAR+CURSOR_HOME+'SHELL_REPAINT\r\n$ ready'+'\x1b[?1000l\x1b[?1006l\x1b[?2004l\x1b[?1l\x1b[5n') + assert.equal(e.term.buffer.active.type,'alternate');assert.equal(e.term.modes.mouseTrackingMode,'none') + assert.equal(e.term.modes.applicationCursorKeysMode,false);assert.equal(e.term.modes.bracketedPasteMode,false) + assert.ok(e.reports.includes('\x1b[0n'));assert.equal(e.visible(),false) + assert.ok(e.copy().lines[0].includes('SHELL_REPAINT'));assert.ok(!e.copy().lines.join('').includes('TUI_FRAME'));assert.deepEqual(e.inputs,[]) + const liveCopy=e.copy();e.write(END);e.win.nt.endViewer('one');await e.drain() + assert.ok(e.visible());assert.deepEqual(e.copy(),liveCopy) + return {generatedComponentFrames:true,innerTuiRuntimeAcceptance:false,liveCopy,queryReports:e.reports,typedInputs:e.inputs,closedOnlyAfterExplicitEof:true} + } finally {e.close()} + }) + const names=cases.map(c=>c.name) + assert.equal(names.length,EXPECTED_CASES.length,'exact component case count') + assert.equal(new Set(names).size,names.length,'unique component case names') + assert.deepEqual(names,EXPECTED_CASES,'exact ordered component case inventory') + const passed=cases.every(c=>c.passed) + process.stdout.write(JSON.stringify({schema:1,passed,fixtureSourceRevision:fixture.provenance.sourceRevision,candidateSha256:digest(scriptPath),harnessSha256:digest(__filename),parser:'actual shipped xterm5.5',environment:'jsdom layout/canvas stubs',cases,physicalAcceptance:false,sshRuntimeAcceptance:false,transientPaintTimingAcceptance:false})+'\n') + process.exitCode=passed?0:1 +} +main().catch(error=>{process.stderr.write(error.stack+'\n');process.exitCode=1}) diff --git a/android/protocol/src/test/interop/terminal-js-driver.cjs b/android/protocol/src/test/interop/terminal-js-driver.cjs new file mode 100644 index 0000000000..ec785fe8dc --- /dev/null +++ b/android/protocol/src/test/interop/terminal-js-driver.cjs @@ -0,0 +1,504 @@ +// Runs the Android app's REAL terminal page script (android/app/src/main/assets/terminal/terminal.js) +// in node, against stubs of xterm.js, the DOM and the WebView bridge, so a JVM test can check what +// the page does. Driven today: the OSC 52 handler (audit A53), the page's focus for the ⌨ chip +// (audit A46), links and the Copy sheet (audit A32), and touch-to-history wheel requests. +// +// Usage: node terminal-js-driver.cjs +// stdin: {"copyLimit": , "osc52": ["", ...], +// "textareaFocused": , "nt": ["", ...], +// "screen": {"cols": n, "rows": n, "viewportY": n, "cellHeight": px, +// "lines": [{"text": "...", "wrapped": , "links": [{"from": col, "to": col, "uri": "..."}]}]}, +// "provideLinks": [<1-based buffer line>, ...], +// "linkHandler": ["", ...], +// "fontSize": px, "originUnavailable": , +// "taps": [{"col": c, "row": , "move": [dx, dy], "moves": [[dx,dy], ...], "fingers": n, +// "startTime": ms, "endTime": ms, "before": [action, ...], "after": [action, ...], +// "frameDelay": ms, "rafInterval": ms}], +// "copySheet": } +// (every field but copyLimit is optional; with no "screen" the buffer is 80×24 and empty) +// stdout: one JSON object: +// {"copyLimitCalls": n, "osc52": [{"returned": , "calls": [[name, arg?], ...]}], +// "nt": [{"fn": name, "focusChanges": ["blur" | "focus", ...], "focusedAfter": bool}], +// "provideLinks": [{"links": null | [{"text", "range", "opened": [url, ...]}]}], +// "linkHandler": [{"opened": [url, ...]}], +// "taps": [{"prevented": bool, "movePrevented": bool, "opened": [url, ...], "scrolls": [[up, notches], ...], +// "scrollsBeforeFrame": [...], "scrollFrames": [{"frame": n, "up": bool, "notches": n}], +// "inputs": [text, ...], "reports": [text, ...], "scrollStops": [{"frame": n, "time": ms}], "endedAt": ms}], +// "copySheet": {"raw": "", "calls": n}, +// "confirmCalls": n} +// where an onCopy call's argument is reported as {"length": n, "sameAsInput": bool}, so a payload of +// several hundred thousand characters never has to be echoed back. The stub textarea follows Blink's +// rule for focus(): on the element that already has focus it returns early, changing nothing, and so +// does blur() on one that has not. focusChanges lists only the changes that took effect. +// +// The stub buffer answers what terminal.js reads of xterm's: getLine(row).isWrapped, +// translateToString(trim) (a row's text padded to `cols` untrimmed, right-trimmed otherwise), and +// getCell(col).extended.urlId for the cells of an OSC 8 link, whose URI `_core._oscLinkService` holds. +// The screen element sits at (4, 2) with 10×20 px cells; a tap is a touchstart at the cell's centre, +// touchmoves at offsets in `moves` (or one `move`), and a touchend at the end point. `fingers` > 1 +// starts with that many touches. A move's optional third entry is its timestamp; untimed events +// default to timestamp0 and never synthesize momentum. `rafInterval` sets following frame intervals. +// +// Actions are {"nt": fn, "args": [...]}, {"event": "touchstart"|"touchmove"|"blur"|"pagehide"|"hidden"| +// "touchcancel"|"multitouch", "move": [dx,dy]}, {"liveTouch": {"type": "touchstart"|"touchmove"|"touchend"|"touchcancel", "x": px, "y": px}}, +// {"checkpoint": label}, {"frame": true}, {"frames": n}, {"timers": true}, {"data": text}, {"report": text}, +// {"mouse": text} or {"binary": text}. Data/paste fire xterm's user-origin event; SGR mouse fires +// it too, while generated reports and legacy mouse do not. +// They run before touchstart or after touchend, before the queued +// animation frames drain deterministically (16ms per frame, or `frameDelay` for the first frame). +// "scrollsBeforeFrame" catches unbatched calls; "inputs"/"reports" record their separate bridge +// paths. "opened" lists URLs. +'use strict' +const fs = require('fs') +const vm = require('vm') + +const scriptPath = process.argv[2] +const input = JSON.parse(fs.readFileSync(0, 'utf8')) + +let current = null +let copyLimitCalls = 0 +let opened = [] +let scrolls = [] +let scrollFrames = [] +let inputs = [] +let reports = [] +let scrollStops = [] +let frameNumber = 0 +let frameTime = 0 +let nextFrameId = 1 +const animationFrames = new Map() +const timers = [] +function requestAnimationFrame(callback) { + const id = nextFrameId++ + animationFrames.set(id, callback) + return id +} +function cancelAnimationFrame(id) { animationFrames.delete(id) } +let copySheetRaw = null +let copySheetCalls = 0 +let confirmCalls = 0 +let lastScrollEpoch = 0 +let historyCloses = 0 +const liveWrites = [] +let liveResets = 0 +const historyResponses = (input.historyResponses || []).slice() +const bridge = { + copyLimit() { + copyLimitCalls++ + return input.copyLimit + }, + onCopy(data) { + current.calls.push(['onCopy', { length: data.length, sameAsInput: data === current.input }]) + }, + onCopyTooLarge() { + current.calls.push(['onCopyTooLarge']) + }, + openUrl(url) { + if (typeof url !== 'string') throw new Error('openUrl takes a string') + opened.push(url) + }, + onCopySheet(json) { + if (typeof json !== 'string') throw new Error('onCopySheet takes a string') + copySheetCalls++ + copySheetRaw = json + }, + onResize() {}, + onReady() {}, + onInput(data) { inputs.push(data) }, + onReport(data) { reports.push(data) }, + onScrollStop() { scrollStops.push({ frame: frameNumber, time: frameTime }) }, + onScroll(up, notches) { + scrolls.push([up, notches]) + scrollFrames.push({ frame: frameNumber, time: frameTime, up, notches }) + }, + onScrollView(up, notches, epoch) { + lastScrollEpoch = epoch + this.onScroll(up, notches) + const response = historyResponses.shift() + if (response) sandbox.window.nt.showScrollView(Buffer.from(JSON.stringify(response)).toString('base64'), epoch) + }, + onHistoryClose() { historyCloses++ } +} + +const screenIn = input.screen || { cols: 80, rows: 24, lines: [] } +const COLS = screenIn.cols +const ROWS = screenIn.rows +const CELL_W = 10 +const CELL_H = screenIn.cellHeight === undefined ? 20 : screenIn.cellHeight +const SCREEN_LEFT = 4 +const SCREEN_TOP = 2 +const linkUris = [] +const rowsData = (screenIn.lines || []).map((l) => { + const text = (l.text || '').slice(0, COLS) + const cellLinks = new Array(COLS).fill(0) + for (const link of l.links || []) { + linkUris.push(link.uri) + const id = linkUris.length + for (let x = link.from; x <= link.to && x < COLS; x++) cellLinks[x] = id + } + return { text, wrapped: !!l.wrapped, cellLinks } +}) +// At least a screenful: rows below the given lines are blank, as in a real buffer. +while (rowsData.length < ROWS) rowsData.push({ text: '', wrapped: false, cellLinks: new Array(COLS).fill(0) }) +const viewportY = screenIn.viewportY === undefined ? Math.max(0, rowsData.length - ROWS) : screenIn.viewportY + +function bufferLine(row) { + const d = rowsData[row] + if (!d) return undefined + return { + isWrapped: d.wrapped, + length: COLS, + translateToString(trim) { + return trim ? d.text.replace(/\s+$/, '') : d.text.padEnd(COLS) + }, + getCell(x) { + if (x < 0 || x >= COLS) return undefined + // A fresh object per read, as xterm's getCell(x) without a cell to reuse. + return { extended: { urlId: d.cellLinks[x] } } + } + } +} + +const oscHandlers = {} +const linkProviders = [] +let textareaFocused = !!input.textareaFocused +let focusChanges = null +const screenElement = { + getBoundingClientRect() { + return { left: SCREEN_LEFT, top: SCREEN_TOP, width: COLS * CELL_W, height: ROWS * CELL_H } + } +} +let createdTerm = null +const userInputListeners = [] +const textareaListeners = {} +class Terminal { + constructor() { + createdTerm = this + this.cols = COLS + this.rows = ROWS + this.modes = {} + this.options = {} + this.element = undefined + this.buffer = { + active: { length: rowsData.length, viewportY, baseY: viewportY, getLine: bufferLine } + } + this._core = { + coreService: input.originUnavailable ? undefined : { + onUserInput(fn) { userInputListeners.push(fn) } + }, + _oscLinkService: { + getLinkData(id) { + return id > 0 && id <= linkUris.length ? { id: String(id), uri: linkUris[id - 1] } : undefined + } + } + } + this.textarea = { + addEventListener(type, fn) { listen(textareaListeners, type, fn) } + } + this.parser = { + registerCsiHandler() { return { dispose() {} } }, + registerOscHandler(ident, fn) { + oscHandlers[ident] = fn + } + } + } + loadAddon() {} + open() { + this.element = { + querySelector(sel) { + return sel === '.xterm-screen' ? screenElement : null + } + } + } + registerLinkProvider(provider) { + linkProviders.push(provider) + return { dispose() {} } + } + onWriteParsed() { return { dispose() {} } } + onData(fn) { this.dataCallback = fn } + onBinary(fn) { this.binaryCallback = fn } + onKey(fn) { this.keyCallback = fn } + write(data) { liveWrites.push(typeof data === 'string' ? data : new TextDecoder().decode(data)) } + reset() { liveResets++ } + focus() { + if (textareaFocused) return + textareaFocused = true + if (focusChanges) focusChanges.push('focus') + } + blur() { + if (!textareaFocused) return + textareaFocused = false + if (focusChanges) focusChanges.push('blur') + } + paste(text) { emitData(text, true) } +} +function emitData(data, fromUser) { + if (fromUser) { + for (const fn of userInputListeners) fn() + if (createdTerm.keyCallback) createdTerm.keyCallback({ key: data }) + } + createdTerm.dataCallback(data) +} +class FitAddonStub { + fit() {} +} +const hostListeners = {} +const windowListeners = {} +const documentListeners = {} +function listen(listeners, type, fn) { + ;(listeners[type] = listeners[type] || []).push(fn) +} +const domElements = [] +function domElement() { + const listeners = {} + const item = { style: {}, hidden: false, textContent: '', children: [], parentElement: null, _listeners: listeners, + appendChild(child) { child.parentElement = this; this.children.push(child) }, + replaceChildren(...children) { + for (const child of this.children) child.parentElement = null + this.children = children + for (const child of children) child.parentElement = this + }, + addEventListener(type, fn) { listen(listeners, type, fn) }, + // A child event reaches the terminal host only if its actual handler permits bubbling. + dispatch(type, fields = {}) { + let propagationStopped = false + const event = { ...fields, type, target: this, defaultPrevented: false, + stopPropagation() { propagationStopped = true }, + preventDefault() { this.defaultPrevented = true } + } + for (let node = this; node; node = node.parentElement) { + event.currentTarget = node + const registered = node === element ? hostListeners : node._listeners + for (const fn of registered[type] || []) fn(event) + if (propagationStopped) break + } + return { propagationStopped, defaultPrevented: event.defaultPrevented } + } + } + domElements.push(item) + return item +} +const element = domElement() +element.addEventListener = (type, fn) => listen(hostListeners, type, fn) + +const sandbox = { + Terminal, + FitAddon: { FitAddon: FitAddonStub }, + document: { getElementById: () => element, createElement: domElement, visibilityState: 'visible', + addEventListener(type, fn) { listen(documentListeners, type, fn) } }, + window: { NodetermBridge: bridge, requestAnimationFrame, cancelAnimationFrame, + addEventListener(type, fn) { listen(windowListeners, type, fn) } }, + performance: { now: () => frameTime }, + setTimeout(callback) { timers.push(callback); return timers.length }, + // xterm's default OSC 8 activation asks with confirm(); the page must never get there. + confirm() { + confirmCalls++ + return false + }, + TextDecoder, + URL, + JSON, + atob +} +vm.createContext(sandbox) +vm.runInContext(fs.readFileSync(scriptPath, 'utf8'), sandbox, { filename: scriptPath }) + +function fail(message) { + process.stdout.write(JSON.stringify({ error: message }) + '\n') + process.exit(0) +} + +const handler = oscHandlers[52] +if (typeof handler !== 'function') fail('terminal.js registered no OSC 52 handler') +const results = [] +for (const data of input.osc52 || []) { + current = { input: data, calls: [] } + const returned = handler(data) + results.push({ returned, calls: current.calls }) +} + +const nt = sandbox.window.nt +const ntResults = [] +for (const fn of input.nt || []) { + if (!nt || typeof nt[fn] !== 'function') fail('terminal.js has no window.nt.' + fn) + focusChanges = [] + nt[fn]() + ntResults.push({ fn, focusChanges, focusedAfter: textareaFocused }) + focusChanges = null +} + +const provided = [] +for (const y of input.provideLinks || []) { + if (linkProviders.length !== 1) fail('terminal.js registered ' + linkProviders.length + ' link providers, not 1') + let reply = 'no callback' + linkProviders[0].provideLinks(y, (links) => { + reply = links + }) + if (reply === 'no callback') fail('the link provider did not answer for line ' + y) + provided.push({ + links: reply + ? reply.map((l) => { + opened = [] + l.activate({ type: 'mouseup' }, l.text) + return { text: l.text, range: l.range, opened } + }) + : null + }) +} + +const handled = [] +for (const uri of input.linkHandler || []) { + opened = [] + const linkHandler = optionsOfTerm().linkHandler + if (!linkHandler || typeof linkHandler.activate !== 'function') fail('terminal.js set no options.linkHandler') + if (linkHandler.allowNonHttpProtocols) fail('options.linkHandler.allowNonHttpProtocols is on') + linkHandler.activate({ type: 'mouseup' }, uri, { start: { x: 1, y: 1 }, end: { x: 1, y: 1 } }) + handled.push({ opened }) +} + +function touchAt(x, y) { + return { clientX: x, clientY: y, identifier: 0 } +} +function dispatch(type, event) { + for (const fn of hostListeners[type] || []) fn(event) +} +const tapped = [] +let checkpoints = [] +let domEvents = [] +function actions(items) { + for (const action of items || []) { + if (action.nt) { + if (typeof nt[action.nt] !== 'function') fail('unknown nt action ' + action.nt) + nt[action.nt](...(action.args || [])) + } else if (action.checkpoint) { + checkpoints.push({ label: action.checkpoint, frame: frameNumber, time: frameTime, + requests: scrolls.length, notches: scrolls.reduce((sum, request) => sum + request[1], 0), + stops: scrollStops.length, framesQueued: animationFrames.size, historyCloses, + historyVisible: !domElements.find(e => e.id === 'history-view').hidden }) + } else if (action.liveTouch) { + const button = domElements.find(e => e.textContent === 'Live') + if (!button || domElements.find(e => e.id === 'history-view').hidden) fail('Live button is not displayed') + const { type, x = 40, y = 20 } = action.liveTouch + if (!['touchstart', 'touchmove', 'touchend', 'touchcancel'].includes(type)) fail('unknown Live touch ' + type) + const touch = touchAt(x, y) + const ended = type === 'touchend' || type === 'touchcancel' + const result = button.dispatch(type, { touches: ended ? [] : [touch], changedTouches: [touch], timeStamp: frameTime }) + domEvents.push({ target: 'Live', type, frame: frameNumber, ...result }) + } else if (action.event) { + if (action.event === 'touchstart') dispatch('touchstart', { touches: [touchAt(10, 10)], timeStamp: frameTime }) + else if (action.event === 'touchmove') dispatch('touchmove', { + touches: [touchAt(10 + action.move[0], 10 + action.move[1])], timeStamp: frameTime, + preventDefault() {} + }) + else if (action.event === 'touchcancel') dispatch('touchcancel', { touches: [], changedTouches: [] }) + else if (action.event === 'multitouch') dispatch('touchstart', { touches: [touchAt(10, 10), touchAt(60, 10)] }) + else if (action.event === 'hidden') { + sandbox.document.visibilityState = 'hidden' + for (const fn of documentListeners.visibilitychange || []) fn() + } else { + for (const fn of windowListeners[action.event] || []) fn() + } + } else if (action.frame) runFrame(action.delay) + else if (action.frames) for (let i = 0; i < action.frames; i++) runFrame(action.delay) + else if (action.timers) { + const callbacks = timers.splice(0) + for (const callback of callbacks) callback() + } + else if (action.data !== undefined) emitData(action.data, true) + else if (action.report !== undefined) emitData(action.report, false) + else if (action.mouse !== undefined) emitData(action.mouse, true) + else if (action.binary !== undefined) createdTerm.binaryCallback(action.binary) + else if (action.history) nt.showScrollView(Buffer.from(JSON.stringify(action.history)).toString('base64'), action.epoch === undefined ? lastScrollEpoch : action.epoch) + else if (action.live) domElements.find(e => e.textContent === 'Live').dispatch('click') + } +} +actions(input.actions) +function runFrame(delay) { + if (!animationFrames.size) return + if (++frameNumber > 300) fail('scroll animation did not drain within 300 frames') + frameTime += delay === undefined ? 16 : delay + const callbacks = Array.from(animationFrames.values()) + animationFrames.clear() + for (const callback of callbacks) callback(frameTime) +} +function drainFrames(firstDelay, frameDelay) { + let first = true + while (animationFrames.size) { + runFrame(first ? firstDelay : frameDelay) + first = false + } +} +if (input.fontSize !== undefined) nt.setFontSize(input.fontSize) +for (const tap of input.taps || []) { + opened = [] + scrolls = [] + scrollFrames = [] + inputs = [] + reports = [] + scrollStops = [] + checkpoints = [] + domEvents = [] + frameNumber = 0 + let prevented = false + let movePrevented = false + const x = SCREEN_LEFT + tap.col * CELL_W + CELL_W / 2 + const y = SCREEN_TOP + tap.row * CELL_H + CELL_H / 2 + const fingers = tap.fingers || 1 + const start = [] + actions(tap.before) + if (tap.startTime !== undefined) frameTime = Math.max(frameTime, tap.startTime) + for (let i = 0; i < fingers; i++) start.push(touchAt(x + i * 50, y)) + dispatch('touchstart', { touches: start, changedTouches: start, timeStamp: tap.startTime || 0, preventDefault() {} }) + let ex = x + let ey = y + for (const [moveIndex, move] of (tap.moves || (tap.move ? [tap.move] : [])).entries()) { + ex = x + move[0] + ey = y + move[1] + const moved = [] + if (move[2] !== undefined) frameTime = Math.max(frameTime, move[2]) + for (let i = 0; i < fingers; i++) moved.push(touchAt(ex + i * 50, ey)) + dispatch('touchmove', { touches: moved, changedTouches: moved, timeStamp: move[2] || 0, + preventDefault() { movePrevented = true } }) + actions((tap.moveActions || [])[moveIndex]) + } + if (tap.endTime !== undefined) frameTime = Math.max(frameTime, tap.endTime) + dispatch('touchend', { + touches: [], + changedTouches: [touchAt(ex, ey)], + timeStamp: tap.endTime || 0, + preventDefault() { + prevented = true + } + }) + actions(tap.after) + const scrollsBeforeFrame = scrolls.slice() + drainFrames(tap.frameDelay, tap.rafInterval) + tapped.push({ prevented, movePrevented, opened, scrolls, scrollsBeforeFrame, scrollFrames, inputs, reports, scrollStops, endedAt: frameTime, checkpoints, domEvents }) +} + +let copySheet = null +if (input.copySheet) { + if (!nt || typeof nt.copySheet !== 'function') fail('terminal.js has no window.nt.copySheet') + nt.copySheet() + copySheet = { raw: copySheetRaw, calls: copySheetCalls } +} + +function optionsOfTerm() { + if (!createdTerm) fail('terminal.js created no Terminal') + return createdTerm.options +} + +process.stdout.write( + JSON.stringify({ + copyLimitCalls, + osc52: results, + nt: ntResults, + provideLinks: provided, + linkHandler: handled, + taps: tapped, + copySheet, + confirmCalls, + history: { hidden: domElements.find(e => e.id === 'history-view').hidden, + rows: domElements.find(e => e.id === 'history-rows').children.map(e => e.textContent) }, liveWrites, liveResets, historyCloses + }) + '\n' +) diff --git a/android/protocol/src/test/interop/terminal-xterm-interaction-driver.cjs b/android/protocol/src/test/interop/terminal-xterm-interaction-driver.cjs new file mode 100644 index 0000000000..edc8cde382 --- /dev/null +++ b/android/protocol/src/test/interop/terminal-xterm-interaction-driver.cjs @@ -0,0 +1,215 @@ +// Runs the actual Android xterm/Fit/page bundles in jsdom. Only browser layout/canvas facilities +// and the Android bridge are supplied; keyboard, IME, focus, mouse encoding and parser replies +// execute xterm's real implementation. This checks the interactions a stub onData cannot model. +// Usage: node terminal-xterm-interaction-driver.cjs ; stdout is one JSON reply. +'use strict' +const { JSDOM } = require('jsdom') +const fs = require('node:fs') +const path = require('node:path') +const scriptPath = process.argv[2] +const assets = process.argv[3] || path.dirname(scriptPath) +const indexPath = process.argv[4] || path.join(assets, 'index.html') +const pageStyles = fs.readFileSync(indexPath, 'utf8').match(/
', { + url: 'https://terminal.test/', runScripts: 'outside-only', pretendToBeVisual: true +}) +const win = dom.window +win.TextDecoder = TextDecoder +win.matchMedia = () => ({ matches: false, addListener() {}, removeListener() {} }) +win.HTMLCanvasElement.prototype.getContext = () => ({ + measureText: () => ({ width: 10, actualBoundingBoxAscent: 16, actualBoundingBoxDescent: 4 }), + fillRect() {}, clearRect() {}, getImageData: () => ({ data: new Uint8Array([0, 0, 0, 255]) }), + createLinearGradient: () => ({ addColorStop() {} }) +}) +Object.defineProperties(win.HTMLElement.prototype, { + clientWidth: { get() { return 526 } }, clientHeight: { get() { return 906 } }, + offsetWidth: { get() { return (this.textContent || '').length * 10 } }, + offsetHeight: { get() { return 20 } } +}) +win.HTMLElement.prototype.getBoundingClientRect = () => ({ left: 4, top: 2, width: 520, height: 900, right: 524, bottom: 902 }) +let now = 0 +let nextFrame = 1 +const frames = new Map() +win.performance.now = () => now +win.requestAnimationFrame = fn => { const id = nextFrame++; frames.set(id, fn); return id } +win.cancelAnimationFrame = id => frames.delete(id) +let terminal +let ready = false +let events = [] +let lastDisplayEpoch = 0 +let copySnapshot +let openedUrls = [] +win.NodetermBridge = { + copyLimit: () => 400000, onResize() {}, onInput: data => events.push({ input: data }), + onReport: data => events.push({ report: data }), onScroll: (up, notches) => events.push({ scroll: { up, notches } }), + onScrollStop: () => events.push({ stop: true }), + onScrollView: (up, notches, epoch) => { lastDisplayEpoch = epoch; events.push({ scroll: { up, notches } }) }, + onCopy() {}, onCopyTooLarge() {}, onCopySheet(raw) { copySnapshot = JSON.parse(raw) }, + openUrl(url) { openedUrls.push(url) }, onReady() { ready = true } +} +win.eval(fs.readFileSync(path.join(assets, 'xterm.js'), 'utf8')) +const Original = win.Terminal +win.Terminal = class extends Original { + constructor(options) { super(options); terminal = this } +} +win.eval(fs.readFileSync(path.join(assets, 'addon-fit.js'), 'utf8')) +win.eval(fs.readFileSync(scriptPath, 'utf8')) +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) +function drainFrames() { + let count = 0 + while (frames.size) { + if (++count > 100) throw new Error('animation frames did not drain') + now += 16 + const batch = [...frames.values()] + frames.clear() + for (const fn of batch) fn(now) + } +} +const write = data => new Promise(resolve => terminal.write(data, resolve)) +async function main() { + try { + for (let n = 0; !ready && n < 100; n++) await pause(5) + if (!ready) throw new Error('actual page never became ready') + terminal.resize(52, 45) + await write('\x1b[?1049h\x1b[?1003h\x1b[?1006h\x1b[?1004h\x1b[?2004h') + terminal._core.coreService.onUserInput(() => events.push({ userOrigin: true })) + const target = terminal.element.querySelector('.xterm-screen') + function touch(type, y, timestamp = 0, touchTarget = target) { + const t = { identifier: 1, target: touchTarget, clientX: 30, clientY: y, pageX: 30, pageY: y } + const event = new win.TouchEvent(type, { bubbles: true, cancelable: true, + touches: type === 'touchend' ? [] : [t], changedTouches: [t] }) + Object.defineProperty(event, 'timeStamp', { value: timestamp }) + now = Math.max(now, timestamp) + touchTarget.dispatchEvent(event) + } + const actions = { + 'touch-only': async () => {}, + 'focus-report': async () => terminal.blur(), + 'mouse-motion-report': async () => target.dispatchEvent(new win.MouseEvent('mousemove', { + bubbles: true, clientX: 31, clientY: 301, buttons: 0 })), + 'cursor-query-reply': async () => write('\x1b[6n'), + 'device-attributes-reply': async () => write('\x1b[c'), + 'legacy-mouse-report': async () => { + await write('\x1b[?1006l') + target.dispatchEvent(new win.MouseEvent('mousemove', { bubbles: true, clientX: 51, clientY: 341, buttons: 0 })) + await write('\x1b[?1006h') + }, + 'keyboard-escape': async () => terminal.textarea.dispatchEvent(new win.KeyboardEvent('keydown', { + bubbles: true, key: 'Escape', code: 'Escape', keyCode: 27, which: 27 })), + 'bracketed-paste': async () => terminal.paste('pasted text'), + 'ime-input': async () => terminal.textarea.dispatchEvent(new win.InputEvent('input', { + bubbles: true, data: '\u65e5', inputType: 'insertText' })), + 'ime-composition': async () => { + terminal.textarea.dispatchEvent(new win.CompositionEvent('compositionstart', { bubbles: true })) + terminal.textarea.value = '\u672c' + terminal.textarea.dispatchEvent(new win.CompositionEvent('compositionupdate', { bubbles: true, data: '\u672c' })) + await pause(1) + terminal.textarea.dispatchEvent(new win.CompositionEvent('compositionend', { bubbles: true, data: '\u672c' })) + await pause(1) + } + } + const results = [] + for (const [name, action] of Object.entries(actions)) { + win.nt.cancelScroll() + terminal.focus() + drainFrames() + events = [] + touch('touchstart', 500) + touch('touchmove', 300) + await action() + touch('touchmove', 100) + touch('touchend', 100) + drainFrames() + results.push({ name, events: events.slice(), notches: events.reduce((sum, event) => sum + (event.scroll?.notches || 0), 0) }) + } + win.nt.cancelScroll() + events = [] + const start = now + 10 + touch('touchstart', 500, start) + touch('touchmove', 300, start + 50) + touch('touchend', 300, start + 55) + terminal.blur() + await write('\x1b[6n') + drainFrames() + const kinetic = { events: events.slice(), notches: events.reduce((sum, event) => sum + (event.scroll?.notches || 0), 0), duration: now - start - 55 } + async function repaintGesture(usePageHitRules) { + win.nt.cancelScroll() + await write('\x1b[H' + Array.from({ length: 45 }, (_, row) => 'initial row ' + row + '\x1b[K').join('\r\n')) + drainFrames() + const span = target.querySelector('.xterm-rows > div:nth-child(25) span') + if (!span) throw new Error('actual DOM renderer did not paint the touch target') + let touchTarget = span + // jsdom has no geometric hit testing. Walk the real computed pointer-events styles to + // select the eligible ancestor, then retain that same target for the entire touch stream. + // The comparison deliberately bypasses these rules to reproduce the old removed-span bug. + if (usePageHitRules) { + while (touchTarget && win.getComputedStyle(touchTarget).pointerEvents === 'none') touchTarget = touchTarget.parentElement + } + if (!touchTarget) throw new Error('page CSS removed every possible touch target') + const host = win.document.getElementById('term') + const seen = [] + const listener = event => seen.push(event.type) + for (const type of ['touchstart', 'touchmove', 'touchend']) host.addEventListener(type, listener) + events = [] + const began = now + 10 + touch('touchstart', 500, began, touchTarget) + touch('touchmove', 480, began + 20, touchTarget) + drainFrames() + await write('\x1b[25;1Hreplacement after remote redraw\x1b[K') + drainFrames() + const originalSpanRemoved = !span.isConnected + touch('touchmove', 400, began + 60, touchTarget) + touch('touchend', 400, began + 65, touchTarget) + drainFrames() + for (const type of ['touchstart', 'touchmove', 'touchend']) host.removeEventListener(type, listener) + return { originalSpanRemoved, targetWasScreen: touchTarget === target, targetStillConnected: touchTarget.isConnected, + hostEvents: seen, notches: events.reduce((sum, event) => sum + (event.scroll?.notches || 0), 0) } + } + const repaint = { removedSpan: await repaintGesture(false), pageHitTarget: await repaintGesture(true), + touchAction: win.getComputedStyle(target).getPropertyValue('touch-action') } + let resets = 0 + const originalReset = terminal.reset.bind(terminal) + terminal.reset = () => { resets++; originalReset() } + const captured = { status: 'history', viewId: 'b3b8e879-6449-4c8c-8529-4c02d6885878', offset: 3, + totalRows: 5, cols: 52, olderTruncated: false, hasOlder: false, hasNewer: true, + rows: [{ text: '界 https://old.example/x', isWrapped: false, section: 'normal' }, + { text: 'inert captured text', isWrapped: false, section: 'alternate' }] } + win.nt.showScrollView(Buffer.from(JSON.stringify(captured)).toString('base64'), lastDisplayEpoch) + const layer = win.document.getElementById('history-view') + const oldRow = win.document.getElementById('history-rows').firstElementChild + events = [] + const beganHistory = now + 10 + touch('touchstart', 500, beganHistory, layer) + touch('touchmove', 480, beganHistory + 20, layer) + drainFrames() + win.nt.showScrollView(Buffer.from(JSON.stringify(captured)).toString('base64'), lastDisplayEpoch) + await write('\x1b[?1h\x1b[?2004l\x1b[Hlive output behind retained history\x1b[6n') + touch('touchmove', 380, beganHistory + 60, layer) + touch('touchend', 380, beganHistory + 65, layer) + drainFrames() + win.nt.copySheet() + // A cell after the wide CJK prefix must map to the displayed URL, not the live buffer. + const tap = new win.TouchEvent('touchstart', { bubbles: true, cancelable: true, + touches: [{ clientX: 84, clientY: 12 }], changedTouches: [{ clientX: 84, clientY: 12 }] }) + layer.dispatchEvent(tap) + layer.dispatchEvent(new win.TouchEvent('touchend', { bubbles: true, cancelable: true, touches: [], + changedTouches: [{ clientX: 84, clientY: 12 }] })) + const history = { visible: !layer.hidden, rowReplaced: !oldRow.isConnected, layerConnected: layer.isConnected, + noHtml: !layer.querySelector('b'), rows: [...win.document.getElementById('history-rows').children].map(row => row.textContent), + copySnapshot, openedUrls, resets, applicationCursor: terminal.modes.applicationCursorKeysMode, + bracketedPaste: terminal.modes.bracketedPasteMode, + liveText: terminal.buffer.active.getLine(terminal.buffer.active.baseY).translateToString(true), + events: events.slice() } + const oldEpoch = lastDisplayEpoch + terminal.paste('explicit input') + win.nt.showScrollView(Buffer.from(JSON.stringify(captured)).toString('base64'), oldEpoch) + history.closedAfterInput = layer.hidden + process.stdout.write(JSON.stringify({ rows: terminal.rows, cols: terminal.cols, + mouseMode: terminal.modes.mouseTrackingMode, results, kinetic, repaint, history }) + '\n') + } finally { + terminal.dispose() + dom.window.close() + } +} +main().catch(error => { console.error(error.stack); process.exitCode = 1 }) diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AckSweepInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AckSweepInteropTest.kt new file mode 100644 index 0000000000..b314076b78 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AckSweepInteropTest.kt @@ -0,0 +1,96 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.ssh.SshScripts +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Actual Android SSH producer + desktop local/remote consumers on their shared .seen contract. */ +class AckSweepInteropTest { + private fun shell(home: File, command: String, input: String = ""): String { + val builder = ProcessBuilder("/bin/sh", "-c", command).redirectErrorStream(true) + builder.environment().putAll(InteropHarness.scratchHomeEnv(home)) + val process = builder.start() + try { + process.outputStream.bufferedWriter().use { it.write(input) } + assertTrue(process.waitFor(15, TimeUnit.SECONDS), "ack command timed out") + val output = process.inputStream.bufferedReader().readText() + assertEquals(0, process.exitValue(), output) + return output + } finally { + if (process.isAlive) process.destroyForcibly() + } + } + + @Test + fun `ack fixture refuses a home that is not the scratch directory before consumption`() { + assumeTrue(InteropHarness.available("ack-sweep"), "node + esbuild are needed for ack interop tests") + val home = Files.createTempDirectory("nt-ack-home-").toFile() + val other = Files.createTempDirectory("nt-ack-other-").toFile() + try { + val ack = File(home, ".nodeterm/acks/owned.seen") + ack.parentFile.mkdirs() + ack.writeText("event-owned") + val failure = assertFailsWith { + InteropHarness.start("ack-sweep", InteropHarness.scratchHomeEnv(home) + mapOf( + "FIXTURE_HOME" to other.path, "FIXTURE_ACK_INITIAL" to "owned" + )) + } + assertTrue(failure.message.orEmpty().contains("needs os.homedir() to be FIXTURE_HOME")) + assertEquals("event-owned", ack.readText()) + assertTrue(other.listFiles().isNullOrEmpty()) + } finally { + home.deleteRecursively() + other.deleteRecursively() + } + } + + @Test + fun `read acks reach their owner across local and remote desktops without stealing other files`() { + assumeTrue(!System.getProperty("os.name").startsWith("Windows"), "direct SSH uses a POSIX host shell") + assumeTrue(InteropHarness.available("ack-sweep"), "node + repo node_modules (npm ci) are needed for interop tests") + val home = Files.createTempDirectory("nt-ack-interop-").toFile() + try { + val ids = listOf("local", "newly-owned", "remote-one", "remote-two", "foreign") + for (id in ids) { + shell(home, SshScripts.ackRead(id, "event-$id")) + assertEquals("event-$id", File(home, ".nodeterm/acks/$id.seen").readText()) + } + InteropHarness.start("ack-sweep", InteropHarness.scratchHomeEnv(home) + mapOf( + "FIXTURE_USERDATA" to home.path, + "FIXTURE_ACK_INITIAL" to "local", + "FIXTURE_ACK_LATER" to "newly-owned", + "FIXTURE_ACK_REMOTE" to "remote-one,remote-two" + )).use { harness -> + fun values(key: String) = harness.ready[key]!!.jsonArray.map { it.jsonPrimitive.content } + assertEquals(listOf("local"), values("consumed")) + assertEquals(listOf("newly-owned"), values("consumedLater")) + assertEquals(listOf("local", "newly-owned"), values("acked")) + assertEquals(values("acked"), values("cleared")) + assertFalse(File(home, ".nodeterm/acks/local.seen").exists()) + assertFalse(File(home, ".nodeterm/acks/newly-owned.seen").exists()) + for (id in listOf("remote-one", "remote-two", "foreign")) { + assertEquals("event-$id", File(home, ".nodeterm/acks/$id.seen").readText()) + } + val outsideAckDir = File(home, ".nodeterm/escape.seen").apply { writeText("keep-outside") } + val remote = shell(home, harness.ready["remoteCommand"]!!.jsonPrimitive.content, + harness.ready["remoteInput"]!!.jsonPrimitive.content + "../escape\n") + assertEquals(listOf("remote-one", "remote-two"), remote.lines().filter { it.isNotEmpty() }) + assertFalse(File(home, ".nodeterm/acks/remote-one.seen").exists()) + assertFalse(File(home, ".nodeterm/acks/remote-two.seen").exists()) + assertEquals("event-foreign", File(home, ".nodeterm/acks/foreign.seen").readText()) + assertEquals("keep-outside", outsideAckDir.readText()) + } + } finally { + home.deleteRecursively() + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AgentLaunchInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AgentLaunchInteropTest.kt new file mode 100644 index 0000000000..af706f24a6 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AgentLaunchInteropTest.kt @@ -0,0 +1,48 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.ProjectsParser +import kotlinx.serialization.json.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class AgentLaunchInteropTest { + @TempDir lateinit var scratch: File + + @Test + fun `real host mirror facts drive Android launch cold resume and wake exactly like desktop`() { + val vocabularies = listOf("untrusted, on-request, never", "on-request, never", "", "never") + val modes = listOf("manual", "auto", "acceptEdits", "plan", "bypassPermissions") + for ((index, values) in vocabularies.withIndex()) for (auto in listOf(false, true)) { + val data = File(scratch, "$index-$auto").apply { mkdirs() } + val help = if (values.isEmpty()) "unknown help" else " -a, --ask-for-approval \n [possible values: $values]\n --sandbox " + InteropHarness.start("launch-parity", mapOf("FIXTURE_USERDATA" to data.path, "FIXTURE_CODEX_HELP" to help, "FIXTURE_CLAUDE_AUTO" to auto.toString())).use { fixture -> + val snapshot = ProjectsParser.parseBlob(fixture.ready.getValue("blob").jsonPrimitive.content) + val settings = assertNotNull(snapshot.status?.settings).let { snapshot.status!!.settings!! } + assertEquals(auto, settings.autoSupported) + assertEquals(if (values.isEmpty()) emptyList() else values.split(", "), settings.codexApprovalValues) + val commands = fixture.ready.getValue("commands").jsonArray + assertEquals(Agent.entries.size * modes.size, commands.size) + val expectedCases = Agent.entries.flatMap { agent -> modes.map { agent.id to it } }.toSet() + val actualCases = commands.map { entry -> entry.jsonObject.let { row -> + row.getValue("agent").jsonPrimitive.content to row.getValue("mode").jsonPrimitive.content + } }.toSet() + assertEquals(expectedCases, actualCases, "every host agent and permission mode must be covered") + for (entry in commands) { + val row = entry.jsonObject + val agent = Agent.of(row.getValue("agent").jsonPrimitive.content)!! + val mode = row.getValue("mode").jsonPrimitive.content + val host = settings.copy(claudePermissionMode = mode) + val launch = row.getValue("launch").jsonPrimitive.content + val resume = row.getValue("resume").jsonPrimitive.content + assertEquals(launch, Launch.launchCommand(agent, host, null, null), "$values $auto $agent $mode launch") + assertEquals(resume, Launch.resumeLine(agent, "interop-session", host, null, null), "$values $auto $agent $mode cold") + assertEquals(resume, Launch.wakeLine(agent, "interop-session", host), "$values $auto $agent $mode wake") + } + } + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AgentPermissionTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AgentPermissionTest.kt new file mode 100644 index 0000000000..0ebae77b58 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AgentPermissionTest.kt @@ -0,0 +1,56 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.MirrorSettings +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class AgentPermissionTest { + private fun settings(mode: String, values: List = emptyList(), auto: Boolean = false) = + MirrorSettings(mode, auto, emptyList(), values) + + @Test + fun `one assembler applies each measured approval policy to launch cold resume and wake`() { + val modes = listOf("manual", "auto", "acceptEdits", "plan", "bypassPermissions") + val suffixes = mapOf( + Agent.CLAUDE to listOf("", "", " --permission-mode acceptEdits", " --permission-mode plan", " --permission-mode bypassPermissions"), + Agent.CODEX to listOf(" --ask-for-approval untrusted", " --ask-for-approval on-request", "", "", " --ask-for-approval never"), + Agent.GEMINI to listOf("", "", " --approval-mode auto_edit", " --approval-mode plan", " --approval-mode yolo"), + Agent.GROK to listOf("", " --permission-mode auto", " --permission-mode acceptEdits", " --permission-mode plan", " --permission-mode bypassPermissions"), + Agent.OPENCODE to List(5) { "" }, Agent.COPILOT to List(5) { "" }, + Agent.ANTIGRAVITY to List(5) { "" } + ) + for (agent in Agent.entries) for ((index, mode) in modes.withIndex()) { + val s = settings(mode, listOf("untrusted", "on-request", "never")) + val suffix = suffixes.getValue(agent)[index] + assertEquals(agent.launchCmd + suffix, Launch.launchCommand(agent, s, null, null), "$agent $mode launch") + val resume = Launch.resumeCommand(agent, "sid-1")!! + assertEquals("cd '/repo' && $resume$suffix", Launch.resumeLine(agent, "sid-1", s, null, "/repo"), "$agent $mode cold") + assertEquals(resume + suffix, Launch.wakeLine(agent, "sid-1", s), "$agent $mode wake") + } + } + + @Test + fun `Codex manual depends on own host vocabulary and unknown host uses stable desktop baseline`() { + for (values in listOf(emptyList(), listOf("on-request", "never"))) { + assertEquals("codex", Launch.launchCommand(Agent.CODEX, settings("manual", values), null, null)) + assertEquals("codex --ask-for-approval on-request", Launch.launchCommand(Agent.CODEX, settings("auto", values), null, null)) + assertEquals("codex resume s --ask-for-approval never", Launch.wakeLine(Agent.CODEX, "s", settings("bypassPermissions", values))) + } + assertEquals("codex", Launch.launchCommand(Agent.CODEX, settings("auto", listOf("never")), null, null)) + assertEquals("codex --ask-for-approval untrusted", Launch.launchCommand(Agent.CODEX, settings("manual", listOf("untrusted")), null, null)) + assertEquals("codex", Launch.launchCommand(Agent.CODEX, settings("manual", listOf("untrusted; echo injected")), null, null)) + } + + @Test + fun `host Claude gate does not downgrade Grok and project override wins for all agents`() { + val s = settings("auto", auto = false) + assertEquals("claude", Launch.launchCommand(Agent.CLAUDE, s, null, null)) + assertEquals("claude --permission-mode auto", Launch.launchCommand(Agent.CLAUDE, s.copy(autoSupported = true), null, null)) + assertEquals("grok --permission-mode auto", Launch.launchCommand(Agent.GROK, s, null, null)) + assertEquals("gemini --approval-mode plan", Launch.launchCommand(Agent.GEMINI, s, null, null, "plan")) + assertEquals("codex", Launch.launchCommand(Agent.CODEX, s, null, null, "manual")) + assertEquals("gemini", Launch.launchCommand(Agent.GEMINI, s, null, null, "constructor")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AllComputersTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AllComputersTest.kt new file mode 100644 index 0000000000..d7d13218b1 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AllComputersTest.kt @@ -0,0 +1,482 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.AgentNodeStatus +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.AllComputers +import dev.nodeterm.protocol.model.ComputerLabel +import dev.nodeterm.protocol.model.ComputerListing +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxFeed +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.InboxNodeNow +import dev.nodeterm.protocol.model.MirrorInbox +import dev.nodeterm.protocol.model.MirrorUsage +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.UsageAccount +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Audit A55: iOS merges the Inbox ("merged events across connections, newest first") and Usage ("one + * section per paired connection that reports `usage`") across every paired computer + * (docs/mobile-usage-inbox.md), where Android showed one computer's, inside that computer's screen, + * and the computers list showed no needs-you count at all. The rules are [AllComputers] and + * [InboxFeed]; the screens are only type-checked, so what they rely on is pinned in their source. + */ +class AllComputersTest { + private fun event( + id: String, + ts: Long, + nodeId: String = "n1", + kind: InboxKind = InboxKind.APPROVAL, + resolved: Boolean = false + ) = InboxEvent( + id = id, ts = ts, nodeId = nodeId, agentId = "claude", sessionId = "s", kind = kind, + title = "t-$id", detail = null, interrupted = false, resolved = resolved, + options = emptyList(), multiSelect = false, pendingId = null + ) + + private fun now(activity: String?, context: Double? = null, updatedAt: Long = 1) = + InboxNodeNow(activity = activity, tool = null, contextPercent = context, prompt = null, updatedAt = updatedAt) + + private fun status( + events: List = emptyList(), + states: Map = emptyMap(), + inboxNodes: Map = emptyMap(), + usage: MirrorUsage? = null + ) = AgentStatusFile( + updatedAt = 1, + nodes = states.mapValuesTo(LinkedHashMap()) { (_, st) -> AgentNodeStatus(st, "claude", "s", null, false, 1, null) }, + settings = null, + usage = usage, + inbox = MirrorInbox(events, inboxNodes), + server = null + ) + + private fun snapshot(status: AgentStatusFile?) = ProjectsSnapshot(emptyList(), emptySet(), status, 1) + + private fun listing(hostId: String, status: AgentStatusFile?, label: String = "Computer $hostId") = + ComputerListing(ComputerLabel(hostId, label), snapshot(status)) + + private fun host(id: String, name: String, user: String = "me", address: String = "10.0.0.$id") = PairedHost( + id = id, name = name, host = address, port = 22, user = user, sshAvailable = true, + hostKeyB64 = null, relay = null, sshHostKeyFingerprint = null, pairedAt = 0 + ) + + private fun usage(updatedAt: Long, vararg accountIds: String?) = MirrorUsage( + updatedAt, + accountIds.map { UsageAccount(it, null, null, "claude", "ok", updatedAt, emptyList()) } + ) + + // ---- the count on each computer's row -------------------------------------------------------- + + @Test + fun `a computer needs you for its open approvals and questions, not for finished or resolved cards`() { + val s = snapshot( + status( + listOf( + event("a", 1, kind = InboxKind.APPROVAL), + event("q", 2, kind = InboxKind.QUESTION), + event("d", 3, kind = InboxKind.DONE), + event("r", 4, kind = InboxKind.APPROVAL, resolved = true) + ) + ) + ) + assertEquals(2, AllComputers.needsYou(s)) + // A computer not listed yet (no mirror) needs nothing, rather than failing the row. + assertEquals(0, AllComputers.needsYou(ProjectsSnapshot.EMPTY)) + assertEquals(0, AllComputers.needsYou(snapshot(null))) + } + + @Test + fun `the merged screen is offered once a second computer is paired`() { + assertFalse(AllComputers.offered(0)) + assertFalse(AllComputers.offered(1)) + assertTrue(AllComputers.offered(2)) + assertTrue(AllComputers.offered(5)) + } + + // ---- what each computer is called ------------------------------------------------------------ + + @Test + fun `computers are called by their pairing names, in pairing order`() { + val labels = AllComputers.labels(listOf(host("1", "Studio"), host("2", " Laptop "), host("3", ""))) + assertEquals( + listOf(ComputerLabel("1", "Studio"), ComputerLabel("2", "Laptop"), ComputerLabel("3", "Computer")), + labels + ) + } + + @Test + fun `two computers with one name are told apart by their address, and the same address by a number`() { + val labels = AllComputers.labels( + listOf( + host("1", "MacBook Pro", user = "ann", address = "10.0.0.5"), + host("2", "macbook pro", user = "bob", address = "10.0.0.9"), + host("3", "Studio"), + // The same computer paired twice: name and address agree. + host("4", "Mini", user = "me", address = "mini.local"), + host("5", "Mini", user = "me", address = "mini.local") + ) + ).map { it.label } + assertEquals( + listOf( + "MacBook Pro (ann@10.0.0.5)", + "macbook pro (bob@10.0.0.9)", + "Studio", + "Mini (me@mini.local) #1", + "Mini (me@mini.local) #2" + ), + labels + ) + assertEquals(labels.size, labels.map { it.lowercase() }.toSet().size, "every label is distinct") + } + + // ---- the merged Inbox ------------------------------------------------------------------------- + + @Test + fun `events of every computer are merged newest first, each card keeping its computer`() { + val a = listing("a", status(listOf(event("a1", 100), event("a2", 300), event("a3", 500)))) + val b = listing("b", status(listOf(event("b1", 200), event("b2", 400)))) + val feed = AllComputers.feed(listOf(a, b)) + // Interleaved by time, not one computer's cards then the other's. + assertEquals(listOf("a3", "b2", "a2", "b1", "a1"), feed.actionable.map { it.event.id }) + assertEquals(listOf("a", "b", "a", "b", "a"), feed.actionable.map { it.hostId }) + // Each card carries its own computer's listing (its titles, context ring and answers). + for (item in feed.actionable) { + assertEquals(if (item.hostId == "a") a else b, item.from) + assertEquals("Computer ${item.hostId}", item.from.computer.label) + } + } + + @Test + fun `open cards come first and the archive under them, each newest first across computers`() { + val a = listing( + "a", + status(listOf(event("a-done", 900, kind = InboxKind.DONE), event("a-q", 100, kind = InboxKind.QUESTION))) + ) + val b = listing( + "b", + status(listOf(event("b-old", 50, resolved = true), event("b-new", 700, kind = InboxKind.APPROVAL))) + ) + val feed = AllComputers.feed(listOf(a, b)) + assertEquals(listOf("b-new", "a-q"), feed.actionable.map { it.event.id }) + assertEquals(listOf("a-done", "b-old"), feed.archived.map { it.event.id }) + // The count on the merged Inbox tab is every row's count, added up. + assertEquals(feed.actionable.size, listOf(a, b).sumOf { AllComputers.needsYou(it.snapshot) }) + } + + @Test + fun `event identity keeps different computers and requests on the same node separate`() { + val a = listing("a", status(listOf(event("e1", 10), event("e2", 30)))) + val b = listing("b", status(listOf(event("e1", 20)))) + val feed = AllComputers.feed(listOf(a, b)) + assertEquals(3, feed.actionable.size) + assertEquals(listOf("a/e2", "b/e1", "a/e1"), feed.actionable.map { it.key }) + assertEquals(listOf("a" to "e2", "b" to "e1", "a" to "e1"), + feed.actionable.map { it.from.computer.hostId to it.event.id }) + assertEquals(setOf("n1"), feed.actionable.map { it.event.nodeId }.toSet(), + "admission must distinguish events even when every request targets the same node") + } + + @Test + fun `cards at the same moment keep the computers' order, then each mirror's own`() { + val a = listing("a", status(listOf(event("a1", 10), event("a2", 10)))) + val b = listing("b", status(listOf(event("b1", 10)))) + assertEquals(listOf("a1", "a2", "b1"), AllComputers.feed(listOf(a, b)).actionable.map { it.event.id }) + assertEquals(listOf("b1", "a1", "a2"), AllComputers.feed(listOf(b, a)).actionable.map { it.event.id }) + } + + @Test + fun `a card's context ring is read from its own computer, even when another has the same node`() { + // A canvas committed to a repo cloned on two computers has the same node ids on both. + val a = listing("a", status(listOf(event("a1", 10, nodeId = "n1")), inboxNodes = mapOf("n1" to now(null, context = 12.0)))) + val b = listing("b", status(listOf(event("b1", 20, nodeId = "n1")), inboxNodes = mapOf("n1" to now(null, context = 87.0)))) + val byId = AllComputers.feed(listOf(a, b)).actionable.associateBy { it.event.id } + assertEquals(12.0, byId.getValue("a1").contextPercent) + assertEquals(87.0, byId.getValue("b1").contextPercent) + } + + @Test + fun `working sessions of every computer are listed in the computers' order, not reshuffled by time`() { + val a = listing( + "a", + status( + states = linkedMapOf("w1" to AgentState.WORKING, "idle" to AgentState.DONE, "quiet" to AgentState.WORKING), + inboxNodes = mapOf( + "w1" to now("Editing a.kt", updatedAt = 5), + "idle" to now("Reading b.kt", updatedAt = 9), + // Working, but reporting nothing it is doing: no live card (as on a computer's own Inbox). + "quiet" to now(null, updatedAt = 9) + ) + ) + ) + val b = listing( + "b", + status( + states = linkedMapOf("w2" to AgentState.WORKING, "w1" to AgentState.WORKING), + inboxNodes = mapOf("w2" to now("Running npm test", updatedAt = 99), "w1" to now("Using Grep", updatedAt = 1)) + ) + ) + val feed = AllComputers.feed(listOf(a, b)) + assertEquals(listOf("a/w1", "b/w2", "b/w1"), feed.working.map { it.key }) + assertEquals(listOf("Editing a.kt", "Running npm test", "Using Grep"), feed.working.map { it.now.activity }) + } + + @Test + fun `a computer not listed yet adds nothing, and the feed of none is empty`() { + val a = listing("a", status(listOf(event("a1", 10)))) + val unlisted = ComputerListing(ComputerLabel("b", "B"), ProjectsSnapshot.EMPTY) + val feed = AllComputers.feed(listOf(a, unlisted)) + assertEquals(listOf("a/a1"), feed.actionable.map { it.key }) + assertTrue(AllComputers.feed(emptyList()).isEmpty) + assertTrue(AllComputers.feed(listOf(unlisted)).isEmpty) + assertFalse(feed.isEmpty) + } + + @Test + fun `one computer's Inbox is the same rule as the merged one`() { + // The computer's own tab draws InboxFeed.of(that computer): sorted newest first like before. + val a = listing( + "a", + status(listOf(event("old", 1), event("done", 3, kind = InboxKind.DONE), event("new", 2, kind = InboxKind.QUESTION))) + ) + val feed = InboxFeed.of(listOf(a)) + assertEquals(listOf("new", "old"), feed.actionable.map { it.event.id }) + assertEquals(listOf("done"), feed.archived.map { it.event.id }) + assertEquals(AllComputers.feed(listOf(a)), feed) + } + + // ---- the merged Usage ------------------------------------------------------------------------ + + @Test + fun `one usage section per computer that reports usage, in the computers' order`() { + val a = listing("a", status(usage = usage(1_000, null, "acct-1")), label = "Studio") + val none = listing("b", status(usage = null), label = "Old desktop") + val empty = listing("c", status(usage = usage(2_000)), label = "Nothing cached") + val unlisted = ComputerListing(ComputerLabel("d", "Not listed"), ProjectsSnapshot.EMPTY) + val e = listing("e", status(usage = usage(3_000, null)), label = "Laptop") + val sections = AllComputers.usage(listOf(a, none, empty, unlisted, e)) + assertEquals(listOf("Studio", "Laptop"), sections.map { it.computer.label }) + assertEquals(listOf(1_000L, 3_000L), sections.map { it.usage.updatedAt }) + assertEquals(listOf(listOf(null, "acct-1"), listOf(null)), sections.map { s -> s.usage.accounts.map { it.accountId } }) + // Each section names its accounts from its own computer's mirror. + assertEquals(a.snapshot.status, sections[0].status) + assertEquals(e.snapshot.status, sections[1].status) + } + + // ---- what the type-checked screens rely on ----------------------------------------------------- + + @Test + fun `each computer's row counts from the session's cached listing and dials nothing`() { + val src = AppSourcePins.ui("HostsScreen.kt") + val row = AppSourcePins.blockAfter(src, "}) { host ->") + AppSourcePins.assertInOrder( + row, + "val snapshot by session.snapshot.collectAsState()", + "val needsYou = AllComputers.needsYou(snapshot)", + "NeedsYouBadge(needsYou)" + ) + // Listing the computers must not connect to them: no connect, refresh or watch from this screen. + for (dial in listOf("ensureConnected", ".refresh(", "refreshNow", "startWatching", "connectionFor", "viaRelay")) { + assertFalse(src.contains(dial), "HostsScreen dials a computer ($dial)") + } + // The entry to the merged screen, offered by the rule, with the total from the same cached listings. + AppSourcePins.assertInOrder( + src, + "if (AllComputers.offered(hosts.size))", + "rememberListings(hosts).sumOf { AllComputers.needsYou(it.snapshot) }", + "nav.push(Route.AllComputers)" + ) + // The Inbox tab's count is the same definition. + assertTrue(AppSourcePins.ui("HostScreen.kt").contains("val needsYou = AllComputers.needsYou(snapshot)")) + // And the listings the screens read are the sessions' cached snapshots. + val listings = AppSourcePins.blockAfter(AppSourcePins.ui("AllComputersScreen.kt"), "internal fun rememberListings(") + assertTrue(listings.contains(".snapshot"), listings) + for (dial in listOf("ensureConnected", "refresh", "startWatching")) assertFalse(listings.contains(dial), listings) + } + + @Test + fun `opening the merged screen re-lists every computer through the gate, without lifting a held refusal`() { + val screen = AppSourcePins.ui("AllComputersScreen.kt") + // Every computer, through HostSession.refresh: refreshNow, its normal connect path, whose relay + // dial asks RelayApprovalGate. As the app's foreground refresh (AUTO): a first approval may show + // its code, but a computer whose approval was refused or unanswered keeps its hold. + assertTrue(screen.contains("fun refreshAll() = hostIds.forEach { graph.connections.session(it).refresh(Trigger.AUTO) }")) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(screen, "LifecycleStartEffect(hostId)"), + "val starting = uiScope.launch", + "yield()", + "session.startWatching(Trigger.AUTO)", + "onStopOrDispose", + "starting.cancel()", + "session.stopWatching(closeWhenUnused = true)" + ) + val connections = AppSourcePins.app("conn/ConnectionManager.kt") + assertTrue(connections.contains("fun refresh(trigger: Trigger = Trigger.USER) {\n scope.launch { refreshNow(trigger) }")) + // A computer's own Try again is the user asking about it: USER lifts its hold. + val status = AppSourcePins.blockAfter(screen, "private fun ComputerStatus(") + AppSourcePins.assertInOrder( + status, + "is ConnState.AwaitingApproval -> ApprovalFor(computer, s.sas)", + "is ConnState.Failed -> Problem(\"${'$'}{computer.label}: ${'$'}{s.message}\") { session.refresh(Trigger.USER) }", + "relayApproval?.let" + ) + assertFalse(screen.contains("refresh()"), "a refresh on this screen with no trigger says nothing about who asked") + // Each visible host now shares the same lifecycle-bound 8 s loop as its own screen. + assertTrue(screen.contains("for (hostId in hostIds) key(hostId)")) + } + + @Test + fun `a computer whose last listing failed says so, also once the failure dropped its connection`() { + // Review of A55: a listing that fails with an unexpected error drops the connection, which + // leaves the session Idle (refreshNow -> disconnect), and the strip drew nothing for Idle. This + // screen re-lists nothing on its own, so the computer's cached cards stayed, looking current. + val connections = AppSourcePins.app("conn/ConnectionManager.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(connections, "suspend fun refreshNow("), + "} catch (e: Exception) {", + // Never a null error: an exception without a message still says the listing failed ("nothing + // found" picks its ending by the relay leg first, review of A27b). + "_lastError.value = (e as? NothingFoundException)?.said(relayLeg()) ?: e.message ?: e.javaClass.simpleName", + "if (e !is HostException) disconnect()" + ) + assertTrue(connections.contains("fun disconnect() = lifetime.disconnect { disconnectOwned() }")) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(connections, "private fun disconnectOwned()"), + "conn = null", "scope.launch(Dispatchers.IO)", "runCatching { c?.close() }", + "_state.value = ConnState.Idle" + ) + // One rule for which states leave a failed listing to the screen: Connected AND Idle. + assertTrue(connections.contains("val showsListError: Boolean get() = this is Connected || this == Idle")) + + // The merged screen's strip: the error with a Try again of its own (USER), under that rule. + val status = AppSourcePins.blockAfter(AppSourcePins.ui("AllComputersScreen.kt"), "private fun ComputerStatus(") + AppSourcePins.assertInOrder( + status, + "val listError by session.lastError.collectAsState()", + "is ConnState.Failed -> Problem(", + "if (state.showsListError) listError?.let { Problem(\"${'$'}{computer.label}: ${'$'}it\") { session.refresh(Trigger.USER) } }", + "relayApproval?.let" + ) + assertFalse( + Regex("""is ConnState\.Connected ->[^\n]*listError""").containsMatchIn(status), + "the strip shows a failed listing only while connected" + ) + // A computer's own screen reads the same rule. + val host = AppSourcePins.ui("HostScreen.kt") + assertTrue(host.contains("if (state.showsListError && err != null) {")) + assertFalse(host.contains("state is ConnState.Connected && err != null"), "HostScreen shows a failed listing only while connected") + } + + @Test + fun `the merged Inbox shows every computer's Inbox while started, and draws through the shared list`() { + val screen = AppSourcePins.ui("AllComputersScreen.kt") + val inbox = AppSourcePins.blockAfter(screen, "private fun AllInbox(") + AppSourcePins.assertInOrder( + inbox, + "LifecycleStartEffect(hostIds)", + "hostIds.map { graph.connections.session(it).onScreen.showInbox() }", + "onStopOrDispose { showing.forEach { it.close() } }", + "InboxFeedList(nav, feed, showComputer = true" + ) + assertTrue(screen.contains("val feed = AllComputers.feed(listings)")) + assertTrue(screen.contains("AllUsage(AllComputers.usage(listings))")) + // The card composables are InboxTab's, not copies (no second EventCard, UsageCard or quick answer). + for (copy in listOf("fun EventCard(", "fun UsageCard(", "QuickActions.answer")) { + assertFalse(screen.contains(copy), "AllComputersScreen copies $copy") + } + assertTrue(AppSourcePins.blockAfter(screen, "private fun AllUsage(").contains("UsageCard(account, section.status)")) + val tab = AppSourcePins.ui("InboxTab.kt") + assertTrue(tab.contains("InboxFeedList(nav, feed, showComputer = false")) + assertTrue(tab.contains("val feed = InboxFeed.of(listOf(ComputerListing(ComputerLabel(hostId, name), snapshot)))")) + } + + @Test + fun `a merged card opens and answers on its own computer`() { + val list = AppSourcePins.blockAfter(AppSourcePins.ui("InboxTab.kt"), "internal fun InboxFeedList(") + // Open: the card's computer and the title from that computer's listing. + AppSourcePins.assertInOrder( + list, + "fun openOn(from: ComputerListing, nodeId: String) =", + "nav.push(Route.Terminal(from.computer.hostId, nodeId, feedTitle(from.snapshot, nodeId)))", + "fun runOn(" + ) + // Answer: that computer's session, its connection, and its relay leg for a node of its SSH projects. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(list, "fun runOn("), + "val session = graph.connections.session(from.computer.hostId)", + "block(session.ensureConnected())", + "block(session.viaRelay())", + "session.refreshNow()" + ) + // Every card binds its actions to ITS listing: the list takes no screen-wide computer or session. + assertTrue( + AppSourcePins.ui("InboxTab.kt").contains( + "internal fun InboxFeedList(nav: Navigator, feed: InboxFeed, showComputer: Boolean, emptyText: String)" + ) + ) + AppSourcePins.assertInOrder( + list, + "items(feed.actionable", + "onOpen = { openOn(item.from, ev.nodeId) }", + "open = { nodeId -> openOn(item.from, nodeId) }", + "run = { label, block, nodeId -> runOn(item.from, label, block, nodeId, ev.id) }", + "items(feed.working", + "openOn(live.from, nodeId)", + "items(feed.archived", + "onOpen = { openOn(item.from, ev.nodeId) }" + ) + } + + @Test + fun `merged answers admit and retire only their own host and event before launching work`() { + val list = AppSourcePins.blockAfter(AppSourcePins.ui("InboxTab.kt"), "internal fun InboxFeedList(") + assertTrue(list.contains("var pendingActions by remember { mutableStateOf(emptySet>()) }")) + val run = AppSourcePins.blockAfter(list, "fun runOn(") + AppSourcePins.assertInOrder( + run, + "val actionKey = from.computer.hostId to eventId", + "if (actionKey in pendingActions) return", + "pendingActions = pendingActions + actionKey", + "val session = graph.connections.session(from.computer.hostId)", + "scope.launch {", + "block(session.ensureConnected())", + "catch (e: CancellationException)", + "throw e", + "catch (e: Exception)", + "finally {", + "pendingActions = pendingActions - actionKey" + ) + val cards = AppSourcePins.blockAfter(list.substringAfter("items(feed.actionable,"), ") { item ->") + AppSourcePins.assertInOrder( + cards, + "val ev = item.event", + "busy = (item.from.computer.hostId to ev.id) in pendingActions", + "run = { label, block, nodeId -> runOn(item.from, label, block, nodeId, ev.id) }" + ) + } + + @Test + fun `the route is saved in the back stack under a name of its own`() { + val main = AppSourcePins.app("MainActivity.kt") + assertTrue(main.contains("data object AllComputers : Route")) + assertTrue(main.contains("Route.AllComputers -> listOf(\"all\")")) + assertTrue(main.contains("\"all\" -> Route.AllComputers")) + assertTrue(main.contains("Route.AllComputers -> AllComputersScreen(nav)")) + // Every earlier route keeps its saved form, so a stack saved before this one decodes unchanged + // (and BackStack drops, with its key, an entry a build does not know: BackStackTest). + for (line in listOf( + "Route.Hosts -> listOf(\"hosts\")", + "is Route.PairHost -> listOfNotNull(\"pair\", r.code)", + "Route.Settings -> listOf(\"settings\")", + "is Route.Host -> listOf(\"host\", r.hostId, r.tab.toString())", + "is Route.Terminal -> listOf(\"terminal\", r.hostId, r.nodeId, r.title)", + "is Route.SourceControl -> listOf(\"git\", r.hostId, r.projectId)" + )) assertTrue(main.contains(line), "the saved form of a route changed: $line") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AppSourcePins.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AppSourcePins.kt new file mode 100644 index 0000000000..7497be66f0 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/AppSourcePins.kt @@ -0,0 +1,43 @@ +package dev.nodeterm.protocol + +import java.io.File +import kotlin.test.assertTrue + +/** + * Reading the app's Compose sources from a JVM test. What only a device can run (Compose focus, the + * system back dispatcher, InputMethodManager) is pinned in the source instead: the calls a fix + * depends on, in the order it depends on them. + */ +object AppSourcePins { + val appSrc = File(InteropHarness.repoRoot, "android/app/src/main/kotlin/dev/nodeterm/android") + val appUi = File(appSrc, "ui") + + fun ui(file: String): String = File(appUi, file).readText() + + /** A source file under the app's package root, e.g. `notify/InboxNotifier.kt`. */ + fun app(path: String): String = File(appSrc, path).readText() + + /** The block that follows the first occurrence of [start] in [source], braces balanced. */ + fun blockAfter(source: String, start: String): String { + val at = source.indexOf(start) + assertTrue(at >= 0, "not found: $start") + val open = source.indexOf('{', at) + var depth = 0 + for (i in open until source.length) { + when (source[i]) { + '{' -> depth++ + '}' -> if (--depth == 0) return source.substring(open, i + 1) + } + } + error("unbalanced block after $start") + } + + fun assertInOrder(block: String, vararg parts: String) { + var from = 0 + for (p in parts) { + val at = block.indexOf(p, from) + assertTrue(at >= 0, "expected `$p` after `${parts.takeWhile { it != p }.lastOrNull()}` in:\n$block") + from = at + p.length + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackStackTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackStackTest.kt new file mode 100644 index 0000000000..e467bb9788 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackStackTest.kt @@ -0,0 +1,148 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.BackStack +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The app's back stack (audit A43). The Navigator and its SaveableStateHolder wiring are only + * type-checked; they file each entry's saved UI state under [BackStack.Entry.key] and drop the state + * of every [BackStack.retired] key. These pin the keys: one per ENTRY, kept while the entry is on the + * stack (and across recreation), retired when it leaves. + */ +class BackStackTest { + private fun counter(): () -> String { + var n = 0 + return { "k${++n}" } + } + + /** The route codec the app uses, reduced: a route is its list of strings. */ + private val codec: (List) -> List? = { it.takeIf { r -> r.firstOrNull() != "pair" } } + + @Test + fun `the same screen twice gets two keys`() { + // A computer's Host screen opened again from a notification must not share state with the one + // below it, so the key cannot be derived from the route. + val s = BackStack.of(listOf("hosts"), "hosts").push("host:a").push("terminal:a:n1").push("host:a") + val keys = s.entries.map { it.key } + assertEquals(keys.size, keys.toSet().size) + assertNotEquals(s.entries[1].key, s.entries[3].key) + } + + @Test + fun `pushing a terminal and popping it keeps the host screen's key, and retires the terminal's`() { + val host = BackStack.of(listOf("hosts"), "hosts", counter()).push("host:a") + val hostKey = host.currentKey + val withTerminal = host.push("terminal:a:n1") + val terminalKey = withTerminal.currentKey + val back = withTerminal.pop()!! + assertEquals(hostKey, back.currentKey) + assertEquals(listOf(terminalKey), back.retired) + assertEquals(emptyList(), back.withoutRetired().retired) + assertEquals(hostKey, back.withoutRetired().currentKey) + } + + @Test + fun `the root is never popped`() { + assertNull(BackStack.of(listOf("hosts"), "hosts").pop()) + } + + @Test + fun `a new entry never reuses a key that still names saved state`() { + // A counter that restarts would hand the new terminal the popped one's key, and with it the + // popped terminal's saved state. + var n = 0 + val source = { "k${(n++ % 3) + 1}" } + val s = BackStack.of(listOf("hosts"), "hosts", source).push("host:a", source).push("terminal:a:n1", source) + val popped = s.pop()!! + val again = popped.push("terminal:a:n1", source) + assertTrue(again.currentKey !in popped.retired) + assertTrue(again.currentKey !in popped.entries.map { it.key }) + } + + @Test + fun `replaceAll retires every entry, even one showing the same screen`() { + val s = BackStack.of(listOf("hosts"), "hosts").push("host:a") + val old = s.entries.map { it.key } + val r = s.replaceAll("hosts") + assertEquals(listOf("hosts"), r.values) + assertEquals(old, r.retired) + assertTrue(r.currentKey !in old) + } + + @Test + fun `retain keeps each surviving entry under its own key and retires the rest`() { + val s = BackStack.of(listOf("hosts"), "hosts").push("host:a").push("host:gone").push("terminal:a:n1") + val keys = s.entries.map { it.key } + val r = s.retain("hosts") { !it.endsWith("gone") } + assertEquals(listOf("hosts", "host:a", "terminal:a:n1"), r.values) + assertEquals(listOf(keys[0], keys[1], keys[3]), r.entries.map { it.key }) + assertEquals(listOf(keys[2]), r.retired) + // Nothing removed: the very same stack. + assertTrue(r.retain("hosts") { true } === r) + } + + @Test + fun `retain that leaves nothing falls back to a new entry and retires everything`() { + val s = BackStack.of(listOf("host:gone"), "hosts") + val r = s.retain("hosts") { false } + assertEquals(listOf("hosts"), r.values) + assertEquals(s.entries.map { it.key }, r.retired) + assertTrue(r.currentKey !in r.retired) + } + + @Test + fun `keys and retired keys survive a save and restore`() { + // A recreation (rotation, process death) restores the SaveableStateHolder's state by key: the + // restored stack must name its entries by the same keys, or the Host screen below a terminal + // comes back on its first tab and scrolled to the top. + val s = BackStack.of(listOf("hosts"), "hosts").push("host:a").push("terminal:a:n1").push("terminal:a:n2").pop()!! + val restored = BackStack.decode(s.encode { it.split(":") }, "hosts", route = { it.joinToString(":") }) + assertEquals(s.entries, restored.entries) + assertEquals(s.retired, restored.retired) + } + + @Test + fun `an entry that no longer restores is dropped and its key retired`() { + val s = BackStack.of(listOf("hosts"), "hosts").push("pair:code") + val pairKey = s.currentKey + val restored = BackStack.decode(s.encode { it.split(":") }, listOf("hosts"), route = codec) + assertEquals(listOf(listOf("hosts")), restored.values) + assertEquals(s.entries[0].key, restored.currentKey) + assertEquals(listOf(pairKey), restored.retired) + } + + @Test + fun `a stack saved before keys existed restores with new, distinct keys`() { + // The audit A22 format: a bare array of routes. + val raw = """[["hosts"],["host","a","0"],["pair","nodeterm://pair?code=x"],["terminal","a","n1","T"]]""" + val restored = BackStack.decode(raw, listOf("hosts"), route = codec) + assertEquals(listOf(listOf("hosts"), listOf("host", "a", "0"), listOf("terminal", "a", "n1", "T")), restored.values) + assertEquals(3, restored.entries.map { it.key }.toSet().size) + assertEquals(emptyList(), restored.retired) + } + + @Test + fun `a corrupted save cannot give two entries one key, nor retire a live one`() { + val raw = """{"entries":[{"key":"a","route":["hosts"]},{"key":"a","route":["host","x","0"]},{"route":["settings"]}],""" + + """"retired":["a","b","b"]}""" + val restored = BackStack.decode(raw, listOf("hosts"), route = codec) + assertEquals(listOf(listOf("hosts"), listOf("host", "x", "0"), listOf("settings")), restored.values) + val keys = restored.entries.map { it.key } + assertEquals("a", keys[0]) + assertEquals(3, keys.toSet().size) + assertEquals(listOf("b"), restored.retired) + } + + @Test + fun `unreadable saves fall back to the root`() { + for (raw in listOf("", "not json", "42", """{"entries":"x"}""", """{"entries":[{"key":"k","route":["pair","c"]}]}""")) { + val restored = BackStack.decode(raw, listOf("hosts"), route = codec) + assertEquals(listOf(listOf("hosts")), restored.values, raw) + assertTrue(restored.currentKey !in restored.retired, raw) + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackupRulesTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackupRulesTest.kt new file mode 100644 index 0000000000..11b894e777 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/BackupRulesTest.kt @@ -0,0 +1,81 @@ +package dev.nodeterm.protocol + +import org.w3c.dom.Element +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Audit A51: `allowBackup="false"` alone did not keep the phone's data on the phone. For an app that + * targets Android 12 (API 31) or later, it stops cloud backup but NOT a device-to-device transfer; + * only the `` section of the manifest's data extraction rules does. The transfer + * copied `nodeterm.hosts` (every paired computer, its SSH pin and the relay deviceId) to the new + * phone, where the Keystore-sealed secrets could not follow. + * + * The rules are resources a JVM cannot hand to Android's backup manager, so this pins the files: the + * manifest points at the rules, both sections exclude every domain, and every preferences file the + * app opens is covered. What a real transfer copies is a device check. The deviceId half of the fix + * (a new box key always comes with a new deviceId) is [PhoneIdentityTest]. + */ +class BackupRulesTest { + private val appMain = File(InteropHarness.repoRoot, "android/app/src/main") + private val androidNs = "http://schemas.android.com/apk/res/android" + + private fun parse(file: File): Element = + DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } + .newDocumentBuilder().parse(file).documentElement + + private fun Element.children(tag: String): List = + (0 until childNodes.length).map { childNodes.item(it) }.filterIsInstance().filter { it.tagName == tag } + + /** Every domain Android's backup rules know; each is its own tree, so each needs its own exclusion. */ + private val domains = listOf( + "root", "file", "database", "sharedpref", "external", + "device_root", "device_file", "device_database", "device_sharedpref" + ) + + private fun application(): Element = parse(File(appMain, "AndroidManifest.xml")).children("application").single() + + private fun rules(): Element { + val ref = application().getAttributeNS(androidNs, "dataExtractionRules") + assertEquals("@xml/data_extraction_rules", ref, "the manifest must name the data extraction rules") + return parse(File(appMain, "res/xml/data_extraction_rules.xml")) + } + + /** The (domain, path) pairs a section excludes. */ + private fun excluded(section: Element): Set> = + section.children("exclude").map { it.getAttribute("domain") to it.getAttribute("path") }.toSet() + + @Test + fun `backups stay off, and the rules cover device transfer as well as the cloud`() { + assertEquals("false", application().getAttributeNS(androidNs, "allowBackup")) + val root = rules() + assertEquals("data-extraction-rules", root.tagName) + for (name in listOf("cloud-backup", "device-transfer")) { + val section = assertNotNull(root.children(name).singleOrNull(), "<$name> is missing: that path copies the app's data") + assertEquals(emptyList(), section.children("include"), "<$name> must not include anything back") + val missing = domains.filter { (it to ".") !in excluded(section) } + assertEquals(emptyList(), missing, "<$name> leaves these domains in: $missing") + } + } + + @Test + fun `every preferences file the app opens is excluded from both`() { + val names = appMain.walkTopDown().filter { it.extension == "kt" } + .flatMap { Regex("""getSharedPreferences\(\s*"([^"]+)"""").findAll(it.readText()).map { m -> m.groupValues[1] } } + .toSet() + assertTrue("nodeterm.hosts" in names && "nodeterm.secure" in names, "found: $names") + val root = rules() + for (name in listOf("cloud-backup", "device-transfer")) { + val ex = excluded(root.children(name).single()) + for (prefs in names) { + // A sharedpref path names the FILE, with its .xml suffix. + val covered = ("sharedpref" to ".") in ex || ("sharedpref" to "$prefs.xml") in ex + assertTrue(covered, "<$name> does not exclude $prefs.xml") + } + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ComposedCompletionTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ComposedCompletionTest.kt new file mode 100644 index 0000000000..1be7db669d --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ComposedCompletionTest.kt @@ -0,0 +1,67 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ComposedCompletion +import dev.nodeterm.protocol.host.ComposedInputResult +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ComposedCompletionTest { + private class Ui { + var draft = "command" + var draftRevision = 0L + var ctrl = true + var ctrlRevision = 0L + var clears = 0 + var deliveredCallbacks = 0 + fun edit(text: String) { draft = text; draftRevision++ } + fun toggleCtrl() { ctrl = !ctrl; ctrlRevision++ } + fun completion(): Pair Unit> { + val revision = draftRevision + return ComposedCompletion(ctrlRevision) to { + deliveredCallbacks++ + ComposedCompletion.clearUnchangedDraft(revision, draftRevision) { draft = ""; draftRevision++; clears++ } + } + } + fun complete(policy: ComposedCompletion, callback: () -> Unit, result: ComposedInputResult, current: Boolean = true) = + policy.complete(result, current, ctrlRevision, { ctrl = false; ctrlRevision++ }, callback) + } + + @Test fun `admission does not clear anything and only a confirmed same-view delivery clears once`() = runBlocking { + val ui = Ui(); val (policy, callback) = ui.completion(); val receipt = CompletableDeferred() + val sending = async { ui.complete(policy, callback, receipt.await()) } + yield(); assertEquals("command", ui.draft); assertTrue(ui.ctrl); assertEquals(0, ui.deliveredCallbacks) + receipt.complete(ComposedInputResult.DELIVERED); assertTrue(sending.await()) + assertEquals("", ui.draft); assertFalse(ui.ctrl); assertEquals(1, ui.clears); assertEquals(1, ui.deliveredCallbacks) + assertFalse(ui.complete(policy, callback, ComposedInputResult.DELIVERED)); assertEquals(1, ui.clears) + } + + @Test fun `refused uncertain and stale positive completions keep the draft and armed Ctrl`() { + for ((result, current) in listOf(ComposedInputResult.refused() to true, + ComposedInputResult.uncertain() to true, ComposedInputResult.DELIVERED to false)) { + val ui = Ui(); val (policy, callback) = ui.completion() + assertFalse(ui.complete(policy, callback, result, current)) + assertEquals("command", ui.draft); assertTrue(ui.ctrl); assertEquals(0, ui.clears); assertEquals(0, ui.deliveredCallbacks) + assertFalse(ui.complete(policy, callback, ComposedInputResult.DELIVERED), "a later reply never upgrades a completed uncertain ticket") + } + } + + @Test fun `editing away and back to identical draft text preserves the new draft after confirmed delivery`() { + val ui = Ui(); val (policy, callback) = ui.completion() + ui.edit("another command"); ui.edit("command") + assertTrue(ui.complete(policy, callback, ComposedInputResult.DELIVERED)) + assertEquals("command", ui.draft); assertEquals(0, ui.clears); assertEquals(1, ui.deliveredCallbacks) + } + + @Test fun `rearming Ctrl while Send awaits receipt keeps the new control state`() { + val ui = Ui(); val (policy, callback) = ui.completion() + ui.toggleCtrl(); ui.toggleCtrl() + assertTrue(ui.complete(policy, callback, ComposedInputResult.DELIVERED)) + assertTrue(ui.ctrl); assertEquals(2L, ui.ctrlRevision); assertEquals("", ui.draft) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ComposedInputTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ComposedInputTest.kt new file mode 100644 index 0000000000..9b699cb0bd --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ComposedInputTest.kt @@ -0,0 +1,163 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ComposedInput +import dev.nodeterm.protocol.host.ComposedInputResult +import dev.nodeterm.protocol.host.ComposedPreparation +import dev.nodeterm.protocol.host.TerminalActions +import dev.nodeterm.protocol.host.TerminalStream +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ComposedInputTest { + @Test fun `a throwing momentum evaluator refuses preparation without spending draft or Ctrl`() = runBlocking { + for (error in listOf(IllegalStateException("renderer gone"), IllegalArgumentException("provider failed"))) { + var late: ((Boolean) -> Unit)? = null + val ready = ComposedPreparation.cancelMomentum { complete -> late = complete; throw error } + assertFalse(ready.await()) + late!!(true) + assertFalse(ready.await(), "a late callback cannot turn failed preparation into delivery") + assertFalse(ComposedPreparation.cancelMomentum { complete -> complete(true); throw error }.await(), + "an immediate callback cannot hide a synchronous provider failure") + val stream = Stream(); val actions = TerminalActions(this, stream) { true } + try { + if (ready.await()) actions.submit(ComposedInput.Control("\u0003")) + assertTrue(stream.inputs.isEmpty()); assertTrue(stream.log.isEmpty()) + } finally { actions.close() } + } + } + + private class Stream : TerminalStream { + override val fresh = false + val log = mutableListOf() + val inputs = mutableListOf() + var onScroll: suspend () -> Unit = {} + var onSubmit: suspend () -> ComposedInputResult = { ComposedInputResult.DELIVERED } + var retired = false + override fun write(text: String) { log += "raw:$text" } + override fun resize(cols: Int, rows: Int) {} + override suspend fun scroll(up: Boolean, lines: Int) { log += "scroll:$up:$lines"; onScroll() } + override suspend fun submitComposed(input: ComposedInput): ComposedInputResult { + log += "send"; inputs += input; return onSubmit() + } + override fun retireComposed() { retired = true } + override suspend fun detach() {} + override suspend fun endSession() {} + } + + @Test fun `paste normalization matches xterm while armed control stays one raw byte`() { + assertEquals(ComposedInput.Paste("one\rtwo\rthree[?1;2c", true), + ComposedInput.Paste("one\r\ntwo\nthree\u001b[?1;2c\u009b", true).normalized()) + for (byte in listOf(0, 3, 26, 27, 31, 127)) { + val input = ComposedInput.Control(byte.toChar().toString()) + assertTrue(input.valid()); assertEquals(input, input.normalized()) + } + assertFalse(ComposedInput.Control("z").valid()) + assertFalse(ComposedInput.Control("\u0003\r").valid()) + assertFalse(ComposedInput.Paste("nul\u0000", true).valid()) + assertTrue(ComposedInput.Paste("é".repeat(ComposedInput.MAX_BYTES / 2), false).valid()) + assertFalse(ComposedInput.Paste("é".repeat(ComposedInput.MAX_BYTES / 2 + 1), false).valid()) + } + + @Test fun `Send cancels unsent momentum waits for the in-flight scroll and preserves reports`() = runBlocking { + val stream = Stream(); val started = CompletableDeferred(); val release = CompletableDeferred() + stream.onScroll = { started.complete(Unit); release.await() } + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.scroll(true, 80)); withTimeout(2_000) { started.await() } + assertTrue(actions.report("\u001b[?1;2c")); assertTrue(actions.scroll(false, 12)) + val sent = async(start = CoroutineStart.UNDISPATCHED) { actions.submit(ComposedInput.Paste("command\n", true)) } + yield(); assertFalse(sent.isCompleted); assertEquals(listOf("scroll:true:20"), stream.log) + release.complete(Unit) + assertEquals(ComposedInputResult.DELIVERED, sent.await()) + assertEquals(listOf("scroll:true:20", "raw:\u001b[?1;2c", "send"), stream.log) + assertEquals(listOf(ComposedInput.Paste("command\r", true)), stream.inputs) + assertTrue(actions.scroll(false, 2)); yield(); assertEquals("scroll:false:2", stream.log.last()) + } finally { actions.close() } + } + + @Test fun `Ctrl Send is composed once without converting it to paste or adding Enter`() = runBlocking { + val stream = Stream(); val actions = TerminalActions(this, stream) { true } + try { + assertEquals(ComposedInputResult.DELIVERED, actions.submit(ComposedInput.Control("\u0003"))) + assertEquals(listOf(ComposedInput.Control("\u0003")), stream.inputs) + assertTrue(actions.write("raw\r")); yield(); assertEquals(listOf("send", "raw:raw\r"), stream.log) + } finally { actions.close() } + } + + @Test fun `refused and uncertain Send results are retained and never automatically replayed`() = runBlocking { + for (result in listOf(ComposedInputResult.refused(), ComposedInputResult.uncertain())) { + val stream = Stream().apply { onSubmit = { result } }; val actions = TerminalActions(this, stream) { true } + try { + assertEquals(result, actions.submit(ComposedInput.Paste("keep draft", true))) + yield(); assertEquals(1, stream.inputs.size) + assertTrue(actions.report("report")); yield(); assertEquals(1, stream.inputs.size) + } finally { actions.close() } + } + } + + @Test fun `unexpected dispatched error is uncertain without raw fallback or replay`() = runBlocking { + val stream = Stream().apply { onSubmit = { throw IllegalStateException("lost receipt") } } + val actions = TerminalActions(this, stream) { true } + try { + assertEquals(ComposedInputResult.Status.UNCERTAIN, actions.submit(ComposedInput.Paste("keep", true)).status) + yield(); assertEquals(listOf("send"), stream.log) + } finally { actions.close() } + } + + @Test fun `retiring a queued Send refuses before dispatch and immediately retires stream permission`() = runBlocking { + val stream = Stream(); val started = CompletableDeferred(); val release = CompletableDeferred() + stream.onScroll = { started.complete(Unit); release.await() } + val actions = TerminalActions(this, stream) { true } + assertTrue(actions.scroll(true, 20)); started.await() + val sent = async(start = CoroutineStart.UNDISPATCHED) { actions.submit(ComposedInput.Paste("stale", true)) } + actions.close() + assertTrue(stream.retired, "retirement cannot wait behind raw I/O or detach") + assertEquals(ComposedInputResult.Status.REFUSED, sent.await().status) + release.complete(Unit); yield(); assertTrue(stream.inputs.isEmpty()) + } + + @Test fun `retiring an in-flight Send makes its completion uncertain and never uses a replacement`() = runBlocking { + val stream = Stream(); val started = CompletableDeferred(); val release = CompletableDeferred() + stream.onSubmit = { started.complete(Unit); release.await(); ComposedInputResult.DELIVERED } + var current = true; val actions = TerminalActions(this, stream) { current } + val sent = async(start = CoroutineStart.UNDISPATCHED) { actions.submit(ComposedInput.Paste("old view", true)) } + started.await(); current = false; actions.close() + assertEquals(ComposedInputResult.Status.UNCERTAIN, sent.await().status) + assertTrue(stream.retired); release.complete(Unit); yield(); assertEquals(1, stream.inputs.size) + } + + @Test fun `cancelling the caller removes a queued Send without disturbing accepted raw input`() = runBlocking { + val stream = Stream(); val started = CompletableDeferred(); val release = CompletableDeferred() + stream.onScroll = { started.complete(Unit); release.await() } + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.scroll(true, 20)); started.await() + val sent = async(start = CoroutineStart.UNDISPATCHED) { actions.submit(ComposedInput.Paste("cancelled", true)) } + sent.cancelAndJoin(); assertTrue(actions.report("still accepted")); release.complete(Unit); yield() + assertEquals(listOf("scroll:true:20", "raw:still accepted"), stream.log) + } finally { actions.close() } + } + + @Test fun `legacy stream refuses composed Send instead of writing raw text`() = runBlocking { + var writes = 0 + val stream = object : TerminalStream { + override val fresh = false + override fun write(text: String) { writes++ } + override fun resize(cols: Int, rows: Int) {} + override suspend fun scroll(up: Boolean, lines: Int) {} + override suspend fun detach() {} + override suspend fun endSession() {} + } + assertEquals(ComposedInputResult.Status.REFUSED, stream.submitComposed(ComposedInput.Paste("draft", true)).status) + assertEquals(0, writes) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ConnectionUsersTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ConnectionUsersTest.kt new file mode 100644 index 0000000000..23e11e66d1 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ConnectionUsersTest.kt @@ -0,0 +1,127 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ConnectionUsers +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import java.util.concurrent.atomic.AtomicInteger +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The review of audit A25: a computer's one connection is shared by the screens that show it and by + * the background jobs that run on it (the periodic Inbox check, each answer given from a + * notification). Each job used to close it when it ended and no screen watched, under another job + * still sending on it, so two answers a few seconds apart (or an answer and the check) cut each other + * off. [ConnectionUsers] closes it only after the LAST user, which the app's HostSession uses. + */ +class ConnectionUsersTest { + @Test + fun `two overlapping background jobs close the connection once, after the last`() = runBlocking { + val users = ConnectionUsers() + var closes = 0 + val firstIn = CompletableDeferred() + val secondIn = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val first = launch { users.hold({ closes++ }) { firstIn.complete(Unit); releaseFirst.await() } } + val second = launch { users.hold({ closes++ }) { secondIn.complete(Unit); releaseSecond.await() } } + firstIn.await() + secondIn.await() + releaseFirst.complete(Unit) + first.join() + assertEquals(0, closes, "the first job to finish closed the connection under the other") + releaseSecond.complete(Unit) + second.join() + assertEquals(1, closes) + } + + @Test + fun `a screen showing the computer keeps the connection open, and leaving it closes nothing`() = runBlocking { + val users = ConnectionUsers() + var closes = 0 + assertTrue(users.watch()) + assertEquals("checked", users.hold({ closes++ }) { "checked" }) + assertEquals(0, closes, "a background job closed the connection of a screen being looked at") + assertTrue(users.unwatch()) + assertEquals(0, closes, "leaving the screen closed the connection (it is kept for a quick return)") + users.hold({ closes++ }) {} + assertEquals(1, closes) + } + + @Test + fun `a screen that leaves while a job runs, and the job closes the connection when it ends`() = runBlocking { + val users = ConnectionUsers() + var closes = 0 + users.watch() + users.hold({ closes++ }) { users.unwatch() } + assertEquals(1, closes) + } + + @Test + fun `a job that fails or is cancelled still closes the connection when it is the last`() = runBlocking { + val users = ConnectionUsers() + var closes = 0 + assertFailsWith { users.hold({ closes++ }) { error("socket closed") } } + assertEquals(1, closes) + val inside = CompletableDeferred() + val job = launch { users.hold({ closes++ }) { inside.complete(Unit); awaitCancellation() } } + inside.await() + job.cancel() + job.join() + assertEquals(2, closes) + } + + @Test + fun `watch and unwatch report the first and the last screen`() { + val users = ConnectionUsers() + assertFalse(users.watched) + assertTrue(users.watch()) + assertFalse(users.watch()) + assertTrue(users.watched) + assertFalse(users.unwatch()) + assertTrue(users.unwatch()) + assertFalse(users.watched) + // One unwatch too many changes nothing (a screen disposed twice). + assertTrue(users.unwatch()) + assertTrue(users.watch()) + } + + @Test + fun `under concurrent jobs the connection is never closed while one of them runs`() = runBlocking { + val users = ConnectionUsers() + val running = AtomicInteger() + val closedUnderAJob = AtomicInteger() + val closes = AtomicInteger() + val close = { + if (running.get() != 0) closedUnderAJob.incrementAndGet() + closes.incrementAndGet() + Unit + } + val jobs = (1..200).map { i -> + launch(Dispatchers.Default) { + delay(Random(i).nextLong(0, 5)) + users.hold(close) { + running.incrementAndGet() + try { + repeat(Random(i).nextInt(1, 4)) { yield() } + delay(Random(i * 7).nextLong(0, 3)) + } finally { + running.decrementAndGet() + } + } + } + } + jobs.forEach { it.join() } + assertEquals(0, closedUnderAJob.get(), "the connection was closed under a job still running on it") + assertTrue(closes.get() >= 1, "nothing closed the connection after the last job") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ContributorDocsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ContributorDocsTest.kt new file mode 100644 index 0000000000..32eeb1d41e --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ContributorDocsTest.kt @@ -0,0 +1,156 @@ +package dev.nodeterm.protocol + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Two setup lines in the contributor docs that sent a reader into a broken checkout, pinned against + * the files they describe: + * + * - Audit A71: android/README.md said "JDK 17+", but the Gradle the wrapper pins cannot run on every + * JDK after 17 (8.14.3 stops at 24; its embedded Kotlin compiler rejects "25" while compiling the + * build scripts). Every Gradle has a newest Java it runs on, so an open-ended claim is false from + * the next JDK release on. A stated range must end where the wrapper's Gradle does, start no lower + * than the bytecode the build targets, and include the JDK CI actually uses. + * - Audit A61: the docs told desktop contributors to run `npm ci --ignore-scripts` before the protocol + * tests. `npm ci` deletes `node_modules`, and the flag skips the node-pty patch and build, so a + * working desktop checkout came out with node-pty unpatched (and on Linux unbuilt). Their existing + * `npm install` was already enough. The flag is CI's, for a machine without the native toolchain; + * any paragraph that mentions it has to say how a desktop checkout recovers. + * + * The audit and the handover are logs that quote the old wording, so they are not read. + */ +class ContributorDocsTest { + private val root = InteropHarness.repoRoot + + /** + * Contributor-facing docs that say how to build or test the Android side. Each is named by a literal + * path so that WorkflowPathFilterTest sees the read and fails while an edit to the doc would not run + * the Android workflow (CONTRIBUTING.md was missing from its filter while this was a list of strings). + */ + private val docs = listOf( + File(root, "CONTRIBUTING.md"), + File(root, "docs/android.md"), + File(root, "android/README.md"), + File(root, "android/tools/typecheck/README.md"), + ) + + /** + * For each Java release, the first Gradle release that can RUN on it (not build for it through a + * toolchain), from the compatibility matrix at docs.gradle.org/current/userguide/compatibility.html. + * A doc that claims a JDK newer than the last entry fails until this table learns it. + */ + private val firstGradleRunningOn = mapOf( + 17 to "7.3", 18 to "7.5", 19 to "7.6", 20 to "8.3", 21 to "8.5", + 22 to "8.8", 23 to "8.10", 24 to "8.14", 25 to "9.1", + ) + + @Test + fun `every stated JDK requirement is a range the wrapper's Gradle can run on`() { + val gradle = wrapperGradleVersion() + val newestJdk = firstGradleRunningOn.filterValues { compareVersions(it, gradle) <= 0 }.keys.maxOrNull() + ?: fail("the wrapper's Gradle $gradle is older than every entry of the table") + val target = jvmTarget() + val ci = ciJavaVersions() + var ranges = 0 + for (doc in docs) { + val path = doc.repoPath() + val text = read(doc) + openEnded.findAll(text).forEach { + fail( + "$path says `${it.value}`, but the wrapper's Gradle $gradle runs on JDK $newestJdk at most. " + + "State a range (`JDK $target–$newestJdk`) instead (audit A71)." + ) + } + for (m in range.findAll(text)) { + ranges++ + val (low, high) = m.destructured.toList().map(String::toInt) + assertTrue( + high <= newestJdk, + "$path says `${m.value}`, but the wrapper's Gradle $gradle cannot run on JDK $high (newest: " + + "$newestJdk). Lower the range or move the wrapper (audit A71)." + ) + assertTrue(low >= target, "$path says `${m.value}`, below the JVM target $target the build compiles for") + assertTrue( + ci.all { it in low..high }, + "$path says `${m.value}`, which leaves out the JDK $ci CI builds with" + ) + } + } + assertTrue(range.containsMatchIn(read(File(root, "android/README.md"))), "android/README.md no longer states a JDK range") + assertTrue(ranges >= 2, "found only $ranges JDK ranges in ${docs.map { it.repoPath() }}; has the reader stopped seeing them?") + } + + @Test + fun `a paragraph that mentions npm ci --ignore-scripts says how a desktop checkout recovers`() { + var mentions = 0 + for (doc in docs) { + val path = doc.repoPath() + for (paragraph in read(doc).split(Regex("""\n\s*\n"""))) { + if (!paragraph.contains("--ignore-scripts")) continue + mentions++ + assertTrue( + paragraph.contains("npm run rebuild"), + "$path mentions `--ignore-scripts` without saying that `npm ci` deletes node_modules and leaves " + + "node-pty unpatched until `npm install` / `npm run rebuild` runs again (audit A61):\n$paragraph" + ) + } + } + // Both docs name the flag so that a contributor who sees it in CI's workflow knows not to copy it. + assertTrue(mentions >= 2, "found only $mentions paragraphs naming --ignore-scripts; has the wording moved?") + } + + @Test + fun `the version comparison orders Gradle releases numerically`() { + assertTrue(compareVersions("8.14.3", "8.14") > 0) + assertTrue(compareVersions("8.14", "8.8") > 0) // not a string comparison + assertTrue(compareVersions("8.14.3", "9.1") < 0) + assertEquals(0, compareVersions("9.1", "9.1.0")) + } + + private fun File.repoPath() = relativeTo(root).invariantSeparatorsPath + + private fun wrapperGradleVersion(): String { + val props = read(File(root, "android/gradle/wrapper/gradle-wrapper.properties")) + return Regex("""distributionUrl=.*gradle-([0-9][0-9.]*)-(?:bin|all)\.zip""").find(props)?.groupValues?.get(1) + ?: fail("no Gradle version in android/gradle/wrapper/gradle-wrapper.properties") + } + + /** The JVM target the protocol module compiles for; the app and the type-check use the same. */ + private fun jvmTarget(): Int { + val build = read(File(root, "android/protocol/build.gradle.kts")) + return Regex("""JvmTarget\.JVM_(\d+)""").find(build)?.groupValues?.get(1)?.toInt() + ?: fail("no JvmTarget in android/protocol/build.gradle.kts") + } + + /** Every `java-version:` the Android workflow's setup-java steps pin. */ + private fun ciJavaVersions(): Set { + val workflow = read(File(root, ".github/workflows/android.yml")) + val versions = Regex("""java-version:\s*['"]?(\d+)""").findAll(workflow).map { it.groupValues[1].toInt() }.toSet() + assertTrue(versions.isNotEmpty(), "no java-version in .github/workflows/android.yml") + return versions + } + + private companion object { + /** `JDK 17+`, `JDK 17 or newer`, `JDK 17 or later`. */ + val openEnded = Regex("""JDK\s*\d+\s*(?:\+|or\s+(?:newer|later|above))""") + + /** `JDK 17–24` or `JDK 17-24`. */ + val range = Regex("""JDK\s*(\d+)\s*[–-]\s*(\d+)""") + + fun read(file: File) = file.readText().replace("\r\n", "\n") + + fun compareVersions(a: String, b: String): Int { + val x = a.split('.').map(String::toInt) + val y = b.split('.').map(String::toInt) + for (i in 0 until maxOf(x.size, y.size)) { + val c = (x.getOrElse(i) { 0 }).compareTo(y.getOrElse(i) { 0 }) + if (c != 0) return c + } + return 0 + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CryptoVectorsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CryptoVectorsTest.kt new file mode 100644 index 0000000000..fb56b4a087 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CryptoVectorsTest.kt @@ -0,0 +1,96 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.crypto.E2ee +import dev.nodeterm.protocol.crypto.TweetNacl +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * Byte-for-byte agreement with the desktop's crypto. The vectors come from + * `scripts/gen-crypto-vectors.cjs`, which runs the desktop's own `tweetnacl` (the library + * `src/main/remote/e2ee.ts` uses) and node's HKDF — so a green run here means a box sealed on the + * phone opens on the desktop and vice versa. + */ +class CryptoVectorsTest { + private val vectors: JsonObject = Json.parseToJsonElement( + javaClass.getResource("/crypto-vectors.json")!!.readText() + ).jsonObject + + private fun JsonObject.b(key: String): ByteArray = B64.decode(this[key]!!.jsonPrimitive.content)!! + private fun JsonObject.s(key: String): String = this[key]!!.jsonPrimitive.content + + @Test + fun `public keys derive exactly as tweetnacl box keyPair fromSecretKey`() { + for (v in vectors["keypairs"]!!.jsonArray.map { it.jsonObject }) { + val kp = BoxKeyPair.fromSecretKey(v.b("secretKey")) + assertEquals(v.s("publicKey"), kp.publicKeyB64) + } + } + + @Test + fun `box before and SAS match the desktop`() { + for (v in vectors["before"]!!.jsonArray.map { it.jsonObject }) { + val shared = E2ee.deriveSharedKey(v.s("bPublic"), v.b("aSecret")) + assertContentEquals(v.b("shared"), shared) + assertEquals(v.s("sas"), E2ee.sasFromSharedKey(shared)) + } + } + + @Test + fun `secretbox seals and opens byte-identically across block boundaries`() { + for (v in vectors["secretbox"]!!.jsonArray.map { it.jsonObject }) { + val sealed = TweetNacl.secretbox(v.b("msg"), v.b("nonce"), v.b("key")) + assertContentEquals(v.b("box"), sealed, "len=${v.b("msg").size}") + val opened = TweetNacl.secretboxOpen(v.b("box"), v.b("nonce"), v.b("key")) + assertNotNull(opened) + assertContentEquals(v.b("msg"), opened) + } + } + + @Test + fun `a tampered box is refused, never partially decrypted`() { + val v = vectors["secretbox"]!!.jsonArray[10].jsonObject + val box = v.b("box") + for (i in box.indices step 7) { + val bad = box.copyOf().also { it[i] = (it[i].toInt() xor 1).toByte() } + assertNull(TweetNacl.secretboxOpen(bad, v.b("nonce"), v.b("key")), "flip at $i") + } + } + + @Test + fun `session key is RFC 5869 HKDF with host nonce first`() { + for (v in vectors["hkdf"]!!.jsonArray.map { it.jsonObject }) { + val key = E2ee.deriveSessionKey(v.b("base"), v.b("hostNonce"), v.b("clientNonce")) + assertContentEquals(v.b("sessionKey"), key) + } + } + + @Test + fun `host id is the relay broker room derivation`() { + for (v in vectors["hostIds"]!!.jsonArray.map { it.jsonObject }) { + assertEquals(v.s("hostId"), E2ee.hostIdFromPublicKeyB64(v.s("publicKey"))) + } + } + + @Test + fun `encrypt and decrypt round trip with a random nonce`() { + val a = BoxKeyPair.generate() + val b = BoxKeyPair.generate() + val ab = E2ee.deriveSharedKey(b.publicKey, a.secretKey) + val ba = E2ee.deriveSharedKey(a.publicKey, b.secretKey) + assertContentEquals(ab, ba) + val msg = "héllo nodeterm".toByteArray() + assertContentEquals(msg, E2ee.decrypt(E2ee.encrypt(msg, ab), ba)) + assertNull(E2ee.decrypt(ByteArray(10), ab)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CtrlModifierTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CtrlModifierTest.kt new file mode 100644 index 0000000000..c43585503a --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CtrlModifierTest.kt @@ -0,0 +1,62 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.CtrlModifier +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** Deterministic delayed consumption of the production policy, without running Android's Handler. */ +class CtrlModifierTest { + @Test + fun `a delayed callback consumes its captured arm only once`() { + val captured = CtrlModifier(armed = true) + val consumed = captured.consume(captured, current = true) + assertFalse(consumed.armed) + assertEquals(1L, consumed.revision) + assertSame(consumed, consumed.consume(captured, current = true)) + } + + @Test + fun `off and on before the delayed callback preserves the newer arm`() { + var modifier = CtrlModifier(armed = true) + val captured = modifier + val delayed = { modifier = modifier.consume(captured, current = true) } + modifier = modifier.withArmed(false).withArmed(true) + delayed() + assertTrue(modifier.armed) + assertEquals(2L, modifier.revision) + } + + @Test + fun `a callback from a stale viewer cannot consume even its unchanged arm`() { + val captured = CtrlModifier(armed = true) + assertSame(captured, captured.consume(captured, current = false)) + assertTrue(captured.armed) + } + + @Test + fun `repeating an old callback cannot consume a subsequently armed snapshot`() { + var modifier = CtrlModifier(armed = true) + val captured = modifier + val delayed = { modifier = modifier.consume(captured, current = true) } + delayed() + modifier = modifier.withArmed(true) + delayed() + delayed() + assertTrue(modifier.armed) + assertEquals(2L, modifier.revision) + } + + @Test + fun `same-value setters preserve the revision and unarmed snapshots consume nothing`() { + val unarmed = CtrlModifier() + assertSame(unarmed, unarmed.withArmed(false)) + assertSame(unarmed, unarmed.consume(unarmed, current = true)) + val armed = unarmed.withArmed(true) + assertEquals(1L, armed.revision) + assertSame(armed, armed.withArmed(true)) + assertEquals(2L, armed.withArmed(false).revision) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CtrlModifierWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CtrlModifierWiringTest.kt new file mode 100644 index 0000000000..99d483a0d1 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/CtrlModifierWiringTest.kt @@ -0,0 +1,56 @@ +package dev.nodeterm.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Source pins for Android delegation; these do not execute a WebView, Compose or main Handler. */ +class CtrlModifierWiringTest { + private fun controller() = AppSourcePins.ui("TerminalController.kt") + .replace(Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL), "") + .replace(Regex("""(?m)^\s*//.*$"""), "") + + @Test + fun `the controller stores one immutable arm and revision snapshot`() { + val source = controller() + assertTrue("initialCtrl: CtrlModifier = CtrlModifier()" in source) + assertTrue("private var ctrlModifier by mutableStateOf(initialCtrl)" in source) + AppSourcePins.assertInOrder(source, "var ctrlArmed: Boolean", "get() = ctrlModifier.armed", + "set(value) { updateCtrl(ctrlModifier.withArmed(value)) }") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "private fun updateCtrl("), + "if (modifier == ctrlModifier) return", "ctrlModifier = modifier", "onCtrlChanged(modifier)") + assertFalse(Regex("""\b(?:var|val) ctrl(?:State|Revision)\b""").containsMatchIn(source), + "Separate flag/revision fields cannot be captured atomically by the bridge") + } + + @Test + fun `raw input consumes one captured arm on its current viewer without delaying the write`() { + val input = AppSourcePins.blockAfter(controller(), "fun onInput(data:") + AppSourcePins.assertInOrder(input, "val s = stream ?: return", "if (!page.isCurrent(gen)) return", + "val modifier = ctrlModifier", "if (modifier.armed && data.length == 1)", + "Keys.ctrl(data)?.let { out = it }", "main.post {") + val posted = AppSourcePins.blockAfter(input, "main.post") + AppSourcePins.assertInOrder(posted, "val current = page.isCurrent(gen) && stream === s", + "updateCtrl(ctrlModifier.consume(modifier, current))") + assertFalse(posted.contains("writeInput("), "Actual bytes stay ordered on the bridge's existing queue path") + assertTrue(input.substringAfter(posted).contains("writeInput(out, s)")) + assertEquals(1, Regex("""\bctrlModifier\b""").findAll(input.substringBefore("main.post")).count(), + "One immutable snapshot supplies both the arm decision and delayed consumption") + assertFalse(input.contains("ctrlArmed")) + val report = AppSourcePins.blockAfter(controller(), "fun onReport(data:") + assertFalse(report.contains("ctrlModifier")) + assertFalse(report.contains("ctrlArmed")) + } + + @Test + fun `composed Send plans from its snapshot and completes against the latest revision`() { + val submit = AppSourcePins.blockAfter(controller(), "fun submit(text:") + assertTrue(controller().contains("modifier: CtrlModifier")) + AppSourcePins.assertInOrder(submit, "InputBar.plan(attached, modifier.armed, text, enter)", "ComposedCompletion(modifier.revision)") + val posted = AppSourcePins.blockAfter(submit, "main.post") + AppSourcePins.assertInOrder(posted, + "val current = webView === view && stream === expected && actions === actor && attached", + "completionPolicy.complete(completion, current, ctrlModifier.revision, { ctrlArmed = false }, onDelivered)") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DeviceChecklistDocsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DeviceChecklistDocsTest.kt new file mode 100644 index 0000000000..d47d03b822 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DeviceChecklistDocsTest.kt @@ -0,0 +1,217 @@ +package dev.nodeterm.protocol + +import org.junit.jupiter.api.Assumptions +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * What the docs admit about the app that has never run on a phone, pinned against the files that make + * it true: + * + * - Audit A65: android/README.md marked every feature ✓ with no word that nothing had been checked on + * a device, and docs/android.md had no device checklist, although CLAUDE.md asks for a numbered one + * for whatever could not be run. The checklist must exist, be numbered, name the finding each item + * checks, and cover every finding the design notes leave to a device ("a device check", "only + * type-checked", "on a device"); the README's feature table must point at it. + * - Audit A50: the only APK to install is the debug build, which AGP marks debuggable, so adb access + * to the phone yields its pairing credentials. While the release build type has no signing config + * (so there is no release to install instead), the README must say so. + * Its recovery must give the phone a new identity (uninstall, or clear its storage) before it pairs + * again, since a re-pair keeps the same keys (review of A50). + * - The camera-app pairing check must name the desktop's switch to the link QR while the QR defaults + * to raw JSON (review of A65). + * + * The audit and the handover are logs, so only the audit's section headings are read (for the ids). + */ +class DeviceChecklistDocsTest { + // Read by literal paths, so WorkflowPathFilterTest makes the Android workflow run when one changes. + private val notes = read(File(InteropHarness.repoRoot, "docs/android.md")) + private val readme = read(File(InteropHarness.repoRoot, "android/README.md")) + + @Test + fun `continuation findings with three digits retain their full identity`() { + assertEquals(setOf("A09", "A100", "A108", "A1000"), + findingIds("A09 A100 A108 A1000 A9 AA100 A100suffix")) + assertEquals(listOf("A09", "A100", "A108"), + auditHeadingIds("## A09\n## A100\n## A108\n### A109\n## A9\n## A100suffix")) + } + + @Test + fun `the device checklist is numbered from 1 and every item names a finding the audit has`() { + val items = checklistItems() + assertTrue( + items.size >= 23, + "the checklist has ${items.size} items; it started from the handover's 23, so has it been gutted?" + ) + assertEquals((1..items.size).toList(), items.map { it.first }, "checklist items must be numbered 1, 2, 3, … in order") + val known = auditIds() + for ((n, text) in items) { + val ids = findingIds(text) + assertTrue(ids.isNotEmpty(), "checklist item $n names no audit finding (A65 marks a baseline check):\n$text") + val unknown = ids - known + assertTrue(unknown.isEmpty(), "checklist item $n names $unknown, which the audit does not have:\n$text") + } + } + + @Test + fun `every finding the design notes leave to a device has a checklist item`() { + val listed = checklistItems().flatMap { findingIds(it.second) }.toSet() + val outside = notes.replace(checklistSection(), "") + var deferring = 0 + for (paragraph in outside.split(Regex("""\n\s*\n"""))) { + if (!leftToDevice.containsMatchIn(paragraph)) continue + deferring++ + val missing = findingIds(paragraph) - listed + assertTrue( + missing.isEmpty(), + "docs/android.md leaves $missing to a device, but the device checklist has no item for it:\n$paragraph" + ) + } + // The notes defer the ⌨ chip, Settings back, the lock screen, live notifications, device + // transfer, the keyboard insets and more; if none is found, the reader has stopped seeing them. + assertTrue(deferring >= 6, "found only $deferring paragraphs that leave something to a device") + } + + @Test + fun `the README's feature table points at the device checklist`() { + assertTrue( + notes.lines().any { it == "## Device checklist" }, + "docs/android.md has no `## Device checklist` heading, which the README's #device-checklist link needs" + ) + val section = section(readme, "## What it does") + val table = section.indexOf("\n|") + assertTrue(table >= 0, "android/README.md's \"What it does\" has no table") + assertTrue( + section.substring(0, table).contains("../docs/android.md#device-checklist"), + "android/README.md's feature table must say, before its first row, that no row has been checked on " + + "a device, and link the device checklist (audit A65):\n$section" + ) + } + + @Test + fun `while no release build is signed, the README says the debug APK is debuggable`() { + val gradle = read(File(InteropHarness.repoRoot, "android/app/build.gradle.kts")).lines().joinToString("\n") { it.substringBefore("//") } + val release = AppSourcePins.blockAfter(gradle, "release {") + Assumptions.assumeFalse( + Regex("""\bsigningConfig\b""").containsMatchIn(release), + "the release build type is signed now: point the README at the release instead of this warning" + ) + val security = section(readme, "## Security") + assertTrue( + Regex("""debuggable""").containsMatchIn(security) && "run-as" in security, + "android/README.md's Security section must warn that the debug APK is debuggable, so adb access " + + "(`run-as`, a debugger) yields the phone's pairing credentials (audit A50):\n$security" + ) + } + + @Test + fun `the README's recovery after adb access gives the phone a new identity before it pairs again`() { + // Review of A50: the first text said "revoke the phone on each computer and pair it again". That + // gives back what the warning says was taken: the SSH seed and the relay box secret are + // get-or-create (NodetermApp, PhoneIdentity), nothing in the app removes them (Forget drops a + // relay token only), and the desktop appends whatever key a pairing sends and re-pins the box + // key. Only an uninstall or clearing the app's storage makes new ones. + val protocolMain = File(InteropHarness.repoRoot, "android/protocol/src/main/kotlin") + val app = (AppSourcePins.appSrc.walkTopDown() + protocolMain.walkTopDown()) + .filter { it.extension == "kt" } + .joinToString("\n") { it.readText() } + assertTrue( + "getOrCreate32(SecureStore.SSH_SEED)" in app, + "the app no longer gets-or-creates its SSH seed; re-check the README's recovery after adb access" + ) + Assumptions.assumeFalse( + Regex("""remove\(\s*(SecureStore\.)?SSH_SEED\b|remove\(\s*(PhoneIdentity\.)?BOX_SECRET\b""").containsMatchIn(app), + "the app can drop its own identity now: point the README's recovery at that instead of an uninstall" + ) + val security = section(readme, "## Security") + // Markdown wraps anywhere, so the phrases are looked for with the line breaks folded away. + val flat = security.replace(Regex("""\s+"""), " ") + val revoke = flat.indexOf("Settings → Phone → Revoke") + assertTrue(revoke >= 0, "android/README.md's Security section no longer tells the user to revoke the phone:\n$security") + val after = flat.substring(revoke) + val renew = Regex("""\b[Uu]ninstall\b.*?\bclear its storage\b""").find(after) + val pair = Regex("""\b[Pp]air\b""").find(after) + assertTrue( + renew != null && pair != null && renew.range.first < pair.range.first, + "android/README.md's recovery after adb access must have the phone uninstall the app or clear its " + + "storage (a new SSH key, box key and device id) after the revoke and before it pairs again: a " + + "re-pair alone trusts the same keys again:\n$security" + ) + } + + @Test + fun `while the pairing QR defaults to JSON, the camera-app check names the desktop's switch`() { + // Review of A65: item 7 had the tester scan the desktop's QR with the camera app, "which hands the + // nodeterm://pair link to the app". The QR is raw JSON unless the user picks the URL form on the + // desktop, so a tester following it would record a false failure. + val pairQr = read(File(InteropHarness.repoRoot, "src/shared/pair-qr.ts")) + val default = Regex("""DEFAULT_PAIR_QR_FORM\s*:\s*PairQrForm\s*=\s*'(\w+)'""").find(pairQr)?.groupValues?.get(1) + ?: fail("src/shared/pair-qr.ts no longer declares DEFAULT_PAIR_QR_FORM as a literal") + Assumptions.assumeTrue(default == "json", "the pairing QR is a link by default now: item 7 can drop the switch") + val phone = read(File(InteropHarness.repoRoot, "src/renderer/components/settings/sections/PhoneSection.tsx")) + val switch = Regex("""qrForm === 'url'\s*\?\s*'[^']*'\s*:\s*"([^"]+)"""").find(phone)?.groupValues?.get(1) + ?: fail("PhoneSection.tsx no longer has the JSON → URL QR switch this test reads its label from") + val items = checklistItems().filter { "nodeterm://pair" in it.second } + assertTrue(items.isNotEmpty(), "no checklist item checks the `nodeterm://pair` link from the camera app") + for ((n, text) in items) { + val flat = text.replace(Regex("""\s+"""), " ") + assertTrue( + "\"$switch\"" in flat && "Settings → Phone" in flat, + "checklist item $n has the camera app open the `nodeterm://pair` link, but the desktop's QR is raw " + + "JSON unless \"$switch\" is chosen in its Settings → Phone; the item must say so:\n$text" + ) + } + } + + /** (number, text) for each item of docs/android.md's device checklist, continuation lines included. */ + private fun checklistItems(): List> { + val items = mutableListOf>() + var open = false + for (line in checklistSection().lines()) { + val start = itemStart.find(line) + when { + start != null -> { + items += start.groupValues[1].toInt() to StringBuilder(line) + open = true + } + open && line.isNotBlank() && line.first().isWhitespace() -> items.last().second.append('\n').append(line) + else -> open = false + } + } + if (items.isEmpty()) fail("docs/android.md's device checklist has no numbered items") + return items.map { it.first to it.second.toString() } + } + + private fun checklistSection(): String = section(notes, "## Device checklist") + + /** From the line [heading] to the next `## ` heading (subsections included). */ + private fun section(text: String, heading: String): String { + val start = text.lines().indexOf(heading) + if (start < 0) fail("no `$heading` heading") + val lines = text.lines() + val end = (start + 1 until lines.size).firstOrNull { lines[it].startsWith("## ") } ?: lines.size + return lines.subList(start, end).joinToString("\n") + } + + private fun auditIds(): Set = + auditHeadingIds(read(File(InteropHarness.repoRoot, "docs/android-audit-2026-09.md"))) + .toSet() + .also { assertTrue(it.size >= 70, "found only ${it.size} findings in the audit; has its heading format changed?") } + + private fun auditHeadingIds(text: String): List = text.lines() + .mapNotNull { Regex("""^## (A\d{2,})$""").find(it)?.groupValues?.get(1) } + + private fun findingIds(text: String): Set = Regex("""\bA\d{2,}\b""").findAll(text).map { it.value }.toSet() + + private fun read(file: File) = file.readText().replace("\r\n", "\n") + + private companion object { + val itemStart = Regex("""^(\d+)\.\s""") + + /** How docs/android.md says that only a phone can settle something. */ + val leftToDevice = Regex("""device check|type-checked|on a device""", RegexOption.IGNORE_CASE) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DictationLanguageTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DictationLanguageTest.kt new file mode 100644 index 0000000000..7173403c76 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DictationLanguageTest.kt @@ -0,0 +1,126 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.Dictation +import dev.nodeterm.protocol.model.DictationLanguage +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DictationLanguageTest { + @Test + fun `system default stays unset and language tags are strictly canonicalized`() { + for (tag in listOf(null, "", " ")) assertNull(DictationLanguage.canonical(tag)) + assertEquals("en-US", DictationLanguage.canonical(" EN-us ")) + assertEquals("zh-Hant-TW", DictationLanguage.canonical("zh-hant-tw")) + assertEquals("de-CH", DictationLanguage.canonical("de-CH")) + for (tag in listOf("en_US", "en-US garbage", "en-US-", "en--US", "und", "x-private", "en\nUS", "en-" + "a".repeat(130))) { + assertNull(DictationLanguage.canonical(tag), tag) + } + } + + @Test + fun `picker presents human readable regional names and drops duplicates malformed roots and locale extensions`() { + val choices = DictationLanguage.choices(listOf(Locale.US, Locale.US, Locale.UK, Locale.GERMANY, Locale.forLanguageTag("de-CH-u-ca-gregory"), Locale.ROOT), Locale.ENGLISH) + assertEquals(4, choices.size) + assertEquals(setOf("en-US", "en-GB", "de-DE", "de-CH"), choices.map { it.tag }.toSet()) + assertEquals("English (United States)", choices.single { it.tag == "en-US" }.label) + assertEquals("German (Switzerland)", choices.single { it.tag == "de-CH" }.label) + assertEquals("System default", DictationLanguage.label(null, Locale.ENGLISH)) + assertEquals("German (Switzerland)", DictationLanguage.label("de-CH", Locale.ENGLISH)) + } + + @Test + fun `bounded catalog keeps base languages and the retained selected language`() { + val many = (0..1800).map { Locale.Builder().setLanguage("aa").setRegion((it % 1000).toString().padStart(3, '0')).setScript(if (it < 1000) "Latn" else "Cyrl").build() } + val choices = DictationLanguage.choices(many + Locale.GERMAN, Locale.ENGLISH, "zu-ZA") + assertEquals(DictationLanguage.MAX_CHOICES, choices.size) + assertTrue(choices.any { it.tag == "de" }) + assertTrue(choices.any { it.tag == "zu-ZA" }) + assertEquals(choices.size, choices.map { it.tag }.distinct().size) + } + + @Test + fun `language and region search is case insensitive bounded and keeps current selection visible`() { + val choices = DictationLanguage.choices(listOf(Locale.US, Locale.UK, Locale.GERMAN, Locale.forLanguageTag("de-CH")), Locale.ENGLISH) + assertEquals(listOf("de-CH"), DictationLanguage.search(choices, "SWITZERLAND").map { it.tag }) + assertEquals(setOf("en-GB", "en-US"), DictationLanguage.search(choices, "english").map { it.tag }.toSet()) + assertTrue(DictationLanguage.search(choices, "missing").isEmpty()) + val many = (0..100).map { DictationLanguage.Choice("en-${it.toString().padStart(3, '0')}", "English $it") } + val visible = DictationLanguage.search(many, "", many.last().tag) + assertEquals(DictationLanguage.MAX_VISIBLE, visible.size) + assertTrue(visible.contains(many.last())) + } + + @Test + fun `language is frozen through partial results finishing and repeated starts then refreshed next utterance`() { + var state = Dictation.step(Dictation.State(), Dictation.Event.Start("draft", "DE-ch")).state + assertEquals("de-CH", state.languageTag) + val ignored = Dictation.step(state, Dictation.Event.Start("replacement", "en-US")) + assertSame(state, ignored.state) + assertNull(ignored.effect) + state = Dictation.step(state, Dictation.Event.Heard("Grüezi")).state + assertEquals("de-CH", state.languageTag) + state = Dictation.step(state, Dictation.Event.Stop).state + assertEquals("de-CH", state.languageTag) + val final = Dictation.step(state, Dictation.Event.Result("Grüezi")) + assertEquals("draft Grüezi", final.draft) + assertNull(final.state.languageTag) + assertEquals(Dictation.Effect.RELEASE, final.effect) + assertEquals("en-US", Dictation.step(final.state, Dictation.Event.Start("next", "en-US")).state.languageTag) + assertNull(Dictation.step(final.state, Dictation.Event.Start("next")).state.languageTag) + } + + @Test + fun `unsupported language is an actual recognizer failure and leaves a partial draft untouched`() { + val start = Dictation.step(Dictation.State(), Dictation.Event.Start("keep", "zu-ZA")) + val heard = Dictation.step(start.state, Dictation.Event.Heard("words")) + val failed = Dictation.step(heard.state, Dictation.Event.Failed(Dictation.Failure.LANGUAGE_NOT_SUPPORTED)) + assertEquals("keep words", heard.draft) + assertNull(failed.draft) + assertEquals(Dictation.Phase.Error(Dictation.Failure.LANGUAGE_NOT_SUPPORTED), failed.state.phase) + assertEquals(Dictation.Effect.RELEASE, failed.effect) + assertNull(Dictation.step(failed.state, Dictation.Event.Result("late")).draft) + assertFalse(failed.state.message!!.contains("phone's language")) + } + + @Test + fun `native recognizer gets the frozen preference only as an optional language extra`() { + val controller = AppSourcePins.ui("DictationController.kt") + assertTrue("Dictation.Event.Start(draft, preferences.languageTag)" in controller) + assertTrue("private val preferences = DictationPreferences(context)" in controller) + val listen = AppSourcePins.blockAfter(controller, "private fun listen()") + AppSourcePins.assertInOrder(listen, "state.languageTag?.let { intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) }", "rec.startListening(intent)") + assertFalse("preferences.languageTag" in listen, "a listening utterance rereads a changing preference") + assertFalse("Locale.getDefault" in listen, "default recognition must omit the extra") + } + + @Test + fun `preference is phone wide validated and system default removes the saved override`() { + val prefs = AppSourcePins.app("data/DictationPreferences.kt") + assertTrue("getSharedPreferences(\"dictation\", Context.MODE_PRIVATE)" in prefs) + assertTrue("get() = DictationLanguage.canonical(prefs.getString(\"languageTag\", null))" in prefs) + AppSourcePins.assertInOrder(prefs, "require(value == null || DictationLanguage.canonical(value) != null)", "if (value == null) edit.remove(\"languageTag\")", "else edit.putString(\"languageTag\", DictationLanguage.canonical(value))", "edit.apply()") + assertFalse("hostId" in prefs) + } + + @Test + fun `settings selects readable languages and persists without terminal submission`() { + val screen = AppSourcePins.ui("SettingsScreen.kt") + val choose = AppSourcePins.blockAfter(screen, "onChoose = { tag ->") + AppSourcePins.assertInOrder(choose, "dictationPreferences.languageTag = tag", "dictationLanguage = dictationPreferences.languageTag", "chooseDictationLanguage = false") + val dialog = AppSourcePins.ui("DictationLanguageDialog.kt") + assertTrue("Locale.getAvailableLocales()" in dialog) + assertTrue("DictationLanguage.search(choices, query, selected)" in dialog) + assertTrue("Search languages" in dialog) + assertTrue("Availability depends on your phone's speech service and its language models." in dialog) + assertTrue("onChoose(null)" in dialog) + assertTrue("onChoose(choice.tag)" in dialog) + assertTrue("choice.label" in dialog) + assertFalse("controller.submit" in dialog) + assertFalse("SpeechRecognizer" in dialog, "choosing a language must not start recording") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DictationTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DictationTest.kt new file mode 100644 index 0000000000..2ea9f9c4af --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/DictationTest.kt @@ -0,0 +1,377 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.Dictation +import dev.nodeterm.protocol.model.Dictation.Effect +import dev.nodeterm.protocol.model.Dictation.Event +import dev.nodeterm.protocol.model.Dictation.Failure +import dev.nodeterm.protocol.model.Dictation.Phase +import org.junit.jupiter.api.Assumptions +import org.w3c.dom.Element +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.util.spi.ToolProvider +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A59: dictation into the terminal's input bar. The rules (the phases, how heard words meet the + * draft, the message for every recognizer error) are [Dictation]'s and tested here; the app's + * DictationController and the screen's wiring cannot run on a JVM and are pinned in their source; what + * the recognizer does on a phone is the device checklist's. + */ +class DictationTest { + /** Runs [events] the way the screen does: the draft follows each step's `draft`, else stays. */ + private class Bar(var draft: String = "") { + var state = Dictation.State() + val effects = mutableListOf() + + fun on(event: Event): Dictation.Step { + val step = Dictation.step(state, event) + state = step.state + step.draft?.let { draft = it } + step.effect?.let { effects += it } + return step + } + + /** The user changes the draft by hand, which the screen reports as [Event.Edited]. */ + fun type(text: String) { + draft = text + on(Event.Edited) + } + } + + @Test + fun `partial results fill an empty draft, and a revised partial replaces the previous one`() { + val bar = Bar() + assertEquals(Effect.START, bar.on(Event.Start(bar.draft)).effect) + assertEquals(Phase.Listening, bar.state.phase) + bar.on(Event.Heard("hello wor")) + assertEquals("hello wor", bar.draft) + assertEquals(Phase.Partial("hello wor"), bar.state.phase) + bar.on(Event.Heard("hello world")) + // Not "hello wor hello world": partial results are the utterance so far, not increments. + assertEquals("hello world", bar.draft) + val final = bar.on(Event.Result("Hello world")) + assertEquals("Hello world", bar.draft) + assertEquals(Phase.Idle, bar.state.phase) + assertEquals(Effect.RELEASE, final.effect) + assertEquals(listOf(Effect.START, Effect.RELEASE), bar.effects) + } + + @Test + fun `heard words are appended to a draft typed before, never replacing it`() { + val bar = Bar("git commit -m") + bar.on(Event.Start(bar.draft)) + bar.on(Event.Heard("fix the")) + assertEquals("git commit -m fix the", bar.draft) + bar.on(Event.Result("fix the build")) + assertEquals("git commit -m fix the build", bar.draft) + + // A draft that ends in a space or a newline gets no second one. + assertEquals("a b", Dictation.join("a ", "b")) + assertEquals("a\nb", Dictation.join("a\n", "b")) + assertEquals("b", Dictation.join("", " b ")) + assertEquals("keep", Dictation.join("keep", " ")) + } + + @Test + fun `a blank final result keeps what the partial results showed`() { + val bar = Bar("ls") + bar.on(Event.Start(bar.draft)) + bar.on(Event.Heard("minus la")) + bar.on(Event.Result("")) + assertEquals("ls minus la", bar.draft) + + val none = Bar("ls") + none.on(Event.Start(none.draft)) + none.on(Event.Result(null)) + assertEquals("ls", none.draft) + assertEquals(Phase.Idle, none.state.phase) + } + + @Test + fun `the mic tapped again asks for the final result, and once more cancels`() { + val bar = Bar() + bar.on(Event.Start("")) + bar.on(Event.Heard("deploy")) + assertEquals(Effect.STOP, bar.on(Event.Stop).effect) + assertEquals(Phase.Finishing, bar.state.phase) + assertTrue(bar.state.active, "still active while the final result is on its way") + // A partial result can still arrive meanwhile. + bar.on(Event.Heard("deploy it")) + assertEquals("deploy it", bar.draft) + assertEquals(Phase.Finishing, bar.state.phase) + bar.on(Event.Result("deploy it")) + assertEquals(Phase.Idle, bar.state.phase) + + // A recognizer that never answers the stop does not keep the button stuck. + val stuck = Bar() + stuck.on(Event.Start("")) + stuck.on(Event.Heard("wait")) + stuck.on(Event.Stop) + val again = stuck.on(Event.Stop) + assertEquals(Effect.CANCEL, again.effect) + assertNull(again.draft, "a cancel leaves the draft as it is") + assertEquals("wait", stuck.draft) + assertEquals(Phase.Idle, stuck.state.phase) + } + + @Test + fun `typing or sending while listening ends the dictation, and its late results are dropped`() { + val bar = Bar("echo") + bar.on(Event.Start(bar.draft)) + bar.on(Event.Heard("hi")) + bar.type("echo hi there") + assertEquals(Effect.CANCEL, bar.effects.last()) + assertEquals(Phase.Idle, bar.state.phase) + // The cancelled recognizer's late results do not write over what the user typed. + assertNull(bar.on(Event.Heard("hi the")).draft) + assertNull(bar.on(Event.Result("hi there")).draft) + assertEquals("echo hi there", bar.draft) + + // Send clears the draft: a later result must not bring the sent text back. + val sent = Bar("make") + sent.on(Event.Start(sent.draft)) + sent.on(Event.Heard("test")) + sent.type("") + assertNull(sent.on(Event.Result("test")).draft) + assertEquals("", sent.draft) + } + + @Test + fun `the screen stopping cancels, keeping what the draft shows`() { + val bar = Bar() + bar.on(Event.Start("")) + bar.on(Event.Heard("half a sent")) + val step = bar.on(Event.Cancel) + assertEquals(Effect.CANCEL, step.effect) + assertNull(step.draft) + assertEquals("half a sent", bar.draft) + assertFalse(bar.state.active) + // Nothing to cancel when idle: no effect. + assertNull(bar.on(Event.Cancel).effect) + } + + @Test + fun `an error ends the dictation with its message, and a late error after a cancel is ignored`() { + val bar = Bar("ls") + bar.on(Event.Start(bar.draft)) + bar.on(Event.Heard("dash")) + val failed = bar.on(Event.Failed(Failure.NETWORK)) + assertEquals(Effect.RELEASE, failed.effect) + assertNull(failed.draft) + assertEquals("ls dash", bar.draft, "what the draft showed stays") + assertEquals(Phase.Error(Failure.NETWORK), bar.state.phase) + assertEquals(Failure.NETWORK.message, bar.state.message) + assertFalse(bar.state.active) + + // OK clears it; so does the next start. + bar.on(Event.Dismiss) + assertEquals(Phase.Idle, bar.state.phase) + bar.on(Event.Failed(Failure.NO_MATCH)) // idle: a stale callback + assertEquals(Phase.Idle, bar.state.phase) + + val again = Bar() + again.on(Event.Start("")) + again.on(Event.Failed(Failure.SPEECH_TIMEOUT)) + assertEquals(Effect.START, again.on(Event.Start("")).effect) + assertEquals(Phase.Listening, again.state.phase) + assertNull(again.state.message) + + // The client error a recognizer reports after its own cancel reaches an idle machine. + val cancelled = Bar() + cancelled.on(Event.Start("")) + cancelled.on(Event.Cancel) + val stale = cancelled.on(Event.Failed(Failure.CLIENT)) + assertEquals(Phase.Idle, cancelled.state.phase) + assertNull(stale.effect) + } + + @Test + fun `a refused microphone says so, and a tap while listening never starts a second recognizer`() { + val bar = Bar() + val denied = bar.on(Event.Denied) + assertEquals(Phase.Error(Failure.INSUFFICIENT_PERMISSIONS), denied.state.phase) + assertNull(denied.effect) + + val listening = Bar() + listening.on(Event.Start("")) + val second = listening.on(Event.Start("other")) + assertNull(second.effect) + assertEquals("", listening.state.base) + assertEquals(listOf(Effect.START), listening.effects) + // A denial arriving while listening (it cannot, but) does not end the dictation. + assertEquals(Phase.Listening, listening.on(Event.Denied).state.phase) + } + + @Test + fun `a blank partial result shows the draft as it was`() { + val bar = Bar("cd") + bar.on(Event.Start(bar.draft)) + bar.on(Event.Heard("src")) + bar.on(Event.Heard(" ")) + assertEquals("cd", bar.draft) + assertEquals(Phase.Listening, bar.state.phase) + } + + @Test + fun `every recognizer error code has its own failure, with a short message`() { + val coded = Failure.entries.filter { it.code != null } + assertEquals((1..15).toList(), coded.map { it.code }) + for (f in coded) assertEquals(f, Failure.of(f.code!!)) + for (code in listOf(0, -1, 16, 99)) assertEquals(Failure.UNKNOWN, Failure.of(code)) + for (f in Failure.entries) { + assertTrue(f.message.isNotBlank() && f.message.endsWith("."), "$f: ${f.message}") + assertTrue(f.message.length <= 80, "$f's message is not short: ${f.message}") + } + } + + @Test + fun `the failures match SpeechRecognizer's ERROR codes in the android jar the type-check compiles against`() { + // The codes are platform API (they never change once published), so they are written out; this + // reads them from the android-all jar tools/typecheck resolves, when that is in the Gradle cache + // (it is wherever the type-check has run; CI runs only this module, so there it is skipped). A + // code a newer Android adds shows up here once the type-check's pin is moved to it. + val typecheck = File(InteropHarness.repoRoot, "android/tools/typecheck/build.gradle.kts").readText() + val version = Regex("""org\.robolectric:android-all:([^"]+)"""").find(typecheck)?.groupValues?.get(1) + ?: error("tools/typecheck/build.gradle.kts no longer names its android-all") + val gradleHome = System.getenv("GRADLE_USER_HOME")?.let(::File) ?: File(System.getProperty("user.home"), ".gradle") + val jar = File(gradleHome, "caches/modules-2/files-2.1/org.robolectric/android-all/$version") + .walkTopDown().firstOrNull { it.name == "android-all-$version.jar" } + Assumptions.assumeTrue(jar != null, "android-all $version is not in the Gradle cache (run the type-check once)") + val javap = ToolProvider.findFirst("javap").orElse(null) + Assumptions.assumeTrue(javap != null, "this JDK has no javap") + val out = StringWriter() + val rc = javap.run(PrintWriter(out), PrintWriter(StringWriter()), "-cp", jar!!.path, "-constants", "android.speech.SpeechRecognizer") + assertEquals(0, rc, out.toString()) + val platform = Regex("""public static final int ERROR_(\w+) = (\d+);""").findAll(out.toString()) + .associate { it.groupValues[1] to it.groupValues[2].toInt() } + assertTrue(platform.size >= 15, "found only $platform in SpeechRecognizer") + val ours = Failure.entries.filter { it.code != null }.associate { it.name to it.code!! } + assertEquals(platform, ours, "Dictation.Failure must have one entry per SpeechRecognizer.ERROR_* code, named after it") + } + + // ---- The app's half, which cannot run on a JVM: pinned in its source. ---- + + private val controller = AppSourcePins.ui("DictationController.kt") + private val screen = AppSourcePins.ui("TerminalScreen.kt") + + /** The source without its comments, for the checks that a call is absent. */ + private fun code(src: String) = src.replace(Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL), "").replace(Regex("""//[^\n]*"""), "") + + @Test + fun `dictation writes the draft and cannot send it`() { + val code = code(controller) + // It has no terminal at all: no controller, no stream, no page. + for (send in listOf("submit(", "TerminalController", "TerminalStream", ".write(", "js(", "\"nt.", "raw(", "key(")) { + assertFalse(send in code, "DictationController reaches the pane through `$send`") + } + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(controller, "private fun on(event: Dictation.Event)"), + "Dictation.step(state, event)", "state = step.state", "step.draft?.let(setDraft)") + // The screen hands it the draft's setter and nothing else. + val setter = AppSourcePins.blockAfter(screen, "DictationController(context.applicationContext)") + assertTrue("entry.edit(cursorAtEnd(it))" in setter, setter) + for (send in listOf("submit(", ".write(", "raw(", "key(", "js(")) + assertFalse(send in setter, "Dictation's setter must not send: $setter") + assertEquals(1, Regex("""DictationController\(""").findAll(screen).count()) + } + + @Test + fun `the recognizer keeps partial results and applies only an explicit utterance language`() { + val listen = AppSourcePins.blockAfter(controller, "private fun listen()") + AppSourcePins.assertInOrder(listen, + "SpeechRecognizer.createSpeechRecognizer(context)", "setRecognitionListener(Listener(rec))", + "RecognizerIntent.ACTION_RECOGNIZE_SPEECH", "RecognizerIntent.LANGUAGE_MODEL_FREE_FORM", + "RecognizerIntent.EXTRA_PARTIAL_RESULTS, true", + "state.languageTag?.let { intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) }", + "rec.startListening(intent)") + assertFalse("EXTRA_PREFER_OFFLINE" in code(controller), "an offline-only request fails where no offline model is installed") + } + + @Test + fun `the recognizer's callbacks are the machine's events, and only the current recognizer's`() { + val listener = AppSourcePins.blockAfter(controller, "private inner class Listener(") + AppSourcePins.assertInOrder(listener, "if (current()) on(Dictation.Event.Heard(firstOf(partialResults)))") + AppSourcePins.assertInOrder(listener, "if (current()) on(Dictation.Event.Result(firstOf(results)))") + AppSourcePins.assertInOrder(listener, "if (current()) on(Dictation.Event.Failed(Dictation.Failure.of(error)))") + assertTrue("private fun current() = recognizer === rec" in listener, listener) + val on = AppSourcePins.blockAfter(controller, "private fun on(event: Dictation.Event)") + AppSourcePins.assertInOrder(on, "Dictation.Effect.START -> listen()") + AppSourcePins.assertInOrder(on, "Dictation.Effect.STOP ->", "stopListening()") + AppSourcePins.assertInOrder(on, "Dictation.Effect.CANCEL ->", "it.cancel()", "release()") + AppSourcePins.assertInOrder(on, "Dictation.Effect.RELEASE -> release()") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(controller, "private fun release()"), "recognizer = null", "rec.destroy()") + assertTrue("SpeechRecognizer.isRecognitionAvailable(context)" in controller) + } + + @Test + fun `the screen asks for the microphone on the first tap, hides the mic without a recognizer, and cancels on stop`() { + AppSourcePins.assertInOrder(screen, + "rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->", + "if (granted) dictation.start(entry.state.value.value.text) else dictation.denied()") + val mic = AppSourcePins.blockAfter(screen, "if (dictation.available) {") + AppSourcePins.assertInOrder(mic, + "dictation.active -> dictation.stop()", + "ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)", + "PackageManager.PERMISSION_GRANTED -> dictation.start(entry.state.value.value.text)", + "else -> askMic.launch(Manifest.permission.RECORD_AUDIO)") + assertFalse("submit" in mic, "the mic sends nothing:\n$mic") + // Any other change to the draft ends a dictation: typing, and Send's clear. + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(screen, "onValueChange = {"), "entry.edit(it)", "dictation.edited()") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(screen, "val send: () -> Unit = {"), + "controller.submit(sent.value.text, enter = true,", "entry.clearUnchangedDraft(sent.revision)", "dictation.edited()") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(screen, "LifecycleStartEffect(dictation)"), "onStopOrDispose { dictation.cancel() }") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(screen, "DisposableEffect(dictation)"), "onDispose { dictation.dispose() }") + AppSourcePins.assertInOrder(screen, "dictation.state.message?.let { msg ->", "dictation.dismiss()") + } + + /** + * Review of A59: the draft was a String, and the field's String overload rebuilds its value as the + * previous one with only the text replaced, keeping the previous cursor (clamped to the new length). + * Dictation sets the text from code, so after it the cursor stayed where it was before (at 0 in an + * empty draft, or mid-draft) and the next keystroke went into the middle of the dictated words: + * typing " -s" after dictating "git status" into an empty draft gave " -sgit status". Holding the + * draft as a TextFieldValue lets the screen say where the cursor goes. How the field and the + * keyboard then behave is the device checklist's (item 61). + */ + @Test + fun `a dictated draft puts the cursor at its end, and only a change to the text ends a dictation`() { + val code = code(screen) + // The field gets the draft with its cursor, so the TextFieldValue overload, not the String one. + AppSourcePins.assertInOrder(code, "entry.state.collectAsState()", "val draft = editor.value") + AppSourcePins.assertInOrder(screen, "OutlinedTextField(", "value = draft,", "onValueChange = {") + // Where dictation's words go (Dictation.join appends them), the cursor goes: after the last + // character, with nothing selected or composing. + assertTrue("private fun cursorAtEnd(text: String) = TextFieldValue(text, TextRange(text.length))" in code, screen) + // Every write of the draft is one of these: dictation's (cursor at the end), Send's clear, or + // the field's own report (the user's cursor). A write that keeps an old cursor, such as + // `draft.copy(text = …)`, would bring the bug back. + val writes = Regex("""\bentry\.(edit|clearUnchangedDraft)\(([^\n]+)""").findAll(code).map { it.groupValues[0].trim() }.toList() + assertEquals(listOf("entry.edit(cursorAtEnd(it))", "entry.clearUnchangedDraft(sent.revision)) dictation.edited()", "entry.edit(it)"), writes, code) + // The TextFieldValue overload also reports changes that only move the cursor or mark the word + // the keyboard composes; those change no text and must not end a dictation (the String overload + // reported text changes only). The comparison is with the draft before this change. + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(screen, "onValueChange = {"), + "val typed = it.text != entry.state.value.value.text", "entry.edit(it)", "if (typed) dictation.edited()") + assertEquals(2, Regex("""dictation\.edited\(\)""").findAll(code).count(), "only typing and Send's clear end a dictation") + } + + @Test + fun `the manifest asks for the microphone, does not require one, and can see the recognition service`() { + val manifest = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true }.newDocumentBuilder() + .parse(File(InteropHarness.repoRoot, "android/app/src/main/AndroidManifest.xml")).documentElement + val ns = "http://schemas.android.com/apk/res/android" + fun Element.kids(tag: String) = (0 until childNodes.length).map { childNodes.item(it) }.filterIsInstance().filter { it.tagName == tag } + assertTrue(manifest.kids("uses-permission").any { it.getAttributeNS(ns, "name") == "android.permission.RECORD_AUDIO" }) + val mic = manifest.kids("uses-feature").singleOrNull { it.getAttributeNS(ns, "name") == "android.hardware.microphone" } + assertEquals("false", mic?.getAttributeNS(ns, "required"), "RECORD_AUDIO implies a required microphone unless this says otherwise") + // Android 11+ package visibility: without it isRecognitionAvailable is false and the mic never shows. + val actions = manifest.kids("queries").flatMap { it.kids("intent") }.flatMap { it.kids("action") }.map { it.getAttributeNS(ns, "name") } + assertTrue("android.speech.RecognitionService" in actions, "found: $actions") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ForegroundRefreshTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ForegroundRefreshTest.kt new file mode 100644 index 0000000000..1cb321d580 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ForegroundRefreshTest.kt @@ -0,0 +1,430 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ConnectionUsers +import dev.nodeterm.protocol.host.ForegroundRefresh +import dev.nodeterm.protocol.host.RelayApprovalGate +import dev.nodeterm.protocol.host.RelayApprovalGate.Trigger +import dev.nodeterm.protocol.host.RelayApprovalRefusedException +import dev.nodeterm.protocol.host.RelayApprovalTimeoutException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The same loop/ownership/serialization used by HostSession, with controllable network and ticks. */ +class ForegroundRefreshTest { + private class Computer(scope: CoroutineScope) { + val users = ConnectionUsers() + val requests = Channel(Channel.UNLIMITED) + val ticks = Channel(Channel.UNLIMITED) + var closes = 0 + var work: suspend (Trigger) -> Unit = {} + val poll: ForegroundRefresh = ForegroundRefresh(scope, users, { closes++ }, refresh = { trigger -> + serialRefresh(trigger) + }, pause = { ticks.receive() }) + + suspend fun serialRefresh(trigger: Trigger): Unit = poll.serial { + requests.send(trigger) + work(trigger) + } + suspend fun next(): Trigger = try { withTimeout(2_000) { requests.receive() } } + catch (e: TimeoutCancellationException) { throw AssertionError("expected a foreground listing within the bounded test deadline", e) } + suspend fun stop(close: Boolean = true) { + val stopped = poll.stop(close) + if (stopped != null) assertTrue(stopped.isCancelled, "STOP must cancel an in-flight loop immediately") + stopped?.join() + } + } + + @Test + fun `All computers starts AUTO and every later tick remains AUTO`() = runBlocking { + val c = Computer(this) + c.poll.start(Trigger.AUTO) + assertEquals(Trigger.AUTO, c.next()) + repeat(3) { + c.ticks.send(Unit) + assertEquals(Trigger.AUTO, c.next()) + } + c.stop() + assertFalse(c.users.watched) + assertEquals(1, c.closes) + c.ticks.send(Unit) + yield() + assertTrue(c.requests.tryReceive().isFailure, "a stopped screen kept polling") + } + + @Test + fun `STOP before deferred startup cancels the first dial and later START makes one loop`() = runBlocking { + val c = Computer(this) + c.poll.start(Trigger.AUTO) + c.stop() // same dispatcher turn, before the queued loop has started + yield() + assertTrue(c.requests.tryReceive().isFailure, "a disposed screen's queued start dialed anyway") + assertEquals(1, c.closes) + c.poll.start(Trigger.AUTO) + assertEquals(Trigger.AUTO, c.next()) + c.stop() + assertEquals(2, c.closes) + } + + @Test + fun `opening an individual computer uses USER then AUTO and retains quick return`() = runBlocking { + val c = Computer(this) + c.poll.start() + assertEquals(Trigger.USER, c.next()) + c.ticks.send(Unit) + assertEquals(Trigger.AUTO, c.next()) + c.stop(close = false) + assertEquals(0, c.closes) + c.poll.start() + assertEquals(Trigger.USER, c.next()) + c.stop(close = false) + assertEquals(0, c.closes) + } + + @Test + fun `two visible screens share one loop and leaving All computers retains the other`() = runBlocking { + val c = Computer(this) + c.poll.start(Trigger.AUTO) + assertEquals(Trigger.AUTO, c.next()) + c.poll.start() + yield() + assertTrue(c.requests.tryReceive().isFailure, "a second watcher started another loop") + assertEquals(null, c.poll.stop(closeWhenUnused = true)) + assertTrue(c.users.watched) + assertEquals(0, c.closes) + c.ticks.send(Unit) + assertEquals(Trigger.AUTO, c.next()) + c.stop(close = false) + assertEquals(0, c.closes, "All computers left a close request under another visible screen") + } + + @Test + fun `STOP cancels an in-flight connect and closes after its cancellation cleanup`() = runBlocking { + val c = Computer(this) + val connecting = CompletableDeferred() + var cancelled = false + c.work = { + connecting.complete(Unit) + try { awaitCancellation() } finally { + assertEquals(0, c.closes, "connection closed before cancelled connect finished its cleanup") + cancelled = true + } + } + c.poll.start(Trigger.AUTO) + connecting.await() + c.stop() + assertTrue(cancelled) + assertEquals(1, c.closes) + assertFalse(c.users.watched) + } + + @Test + fun `STOP cancels an in-flight listing without publishing or announcing stale cards`() = runBlocking { + val c = Computer(this) + val listing = CompletableDeferred() + val release = CompletableDeferred() + var snapshot = "cached" + val announced = mutableListOf() + c.work = { + listing.complete(Unit) + release.await() + snapshot = "new" + announced += snapshot + } + c.poll.start(Trigger.AUTO) + listing.await() + c.stop() + release.complete(Unit) + yield() + assertEquals("cached", snapshot) + assertTrue(announced.isEmpty()) + assertEquals(1, c.closes) + } + + @Test + fun `STOP closes an idle connection immediately and resume makes exactly one new loop`() = runBlocking { + val c = Computer(this) + c.poll.start(Trigger.AUTO) + c.next() + yield() // waiting in pause, not in a listing + val stopped = c.poll.stop(closeWhenUnused = true) + assertEquals(1, c.closes) + stopped?.join() + c.poll.start(Trigger.AUTO) + assertEquals(Trigger.AUTO, c.next()) + c.ticks.send(Unit) + assertEquals(Trigger.AUTO, c.next()) + c.stop() + assertEquals(2, c.closes) + } + + @Test + fun `a background answer retains its connection when All computers stops`() = runBlocking { + val c = Computer(this) + val answering = CompletableDeferred() + val answered = CompletableDeferred() + val worker = launch { c.users.hold({ c.closes++ }) { answering.complete(Unit); answered.await() } } + answering.await() + c.poll.start(Trigger.AUTO) + c.next() + c.stop() + assertEquals(0, c.closes, "STOP closed under a background answer") + answered.complete(Unit) + worker.join() + assertEquals(1, c.closes) + } + + @Test + fun `an old cancelled poll cannot close a new watch even after its new screen leaves`() = runBlocking { + val c = Computer(this) + val oldEntered = CompletableDeferred() + val cleanup = CompletableDeferred() + c.work = { + oldEntered.complete(Unit) + try { awaitCancellation() } + finally { withContext(NonCancellable) { cleanup.await() } } + } + c.poll.start(Trigger.AUTO) + oldEntered.await() + try { + val old = c.poll.stop(closeWhenUnused = true) + assertEquals(0, c.closes) + val newerListing = CompletableDeferred() + c.work = { newerListing.complete(Unit); awaitCancellation() } + c.poll.start() // clears the old screen's deferred close + yield() // waits for the old listing mutex + assertTrue(c.users.watched) + cleanup.complete(Unit) + old?.join() + assertEquals(Trigger.AUTO, c.next()) // old request was already queued + assertEquals(Trigger.USER, c.next()) + newerListing.await() + assertEquals(0, c.closes) + c.stop(close = false) + assertEquals(0, c.closes, "old cleanup closed the quick-return connection of a newer watch") + } finally { cleanup.complete(Unit) } + } + + @Test + fun `manual push and poll refreshes serialize listing publication and announcement together`() = runBlocking { + val c = Computer(this) + val firstListed = CompletableDeferred() + val releaseAnnounce = CompletableDeferred() + val order = mutableListOf() + var version = 0 + c.work = { + val own = ++version + order += "listed:$own" + order += "published:$own" + if (own == 1) { firstListed.complete(Unit); releaseAnnounce.await() } + order += "announced:$own" + } + c.poll.start(Trigger.AUTO) + firstListed.await() + val pushed = c.poll.changed()!! + val manual = launch { c.serialRefresh(Trigger.USER) } + yield() + assertEquals(listOf("listed:1", "published:1"), order, "another listing overtook an unfinished publication") + releaseAnnounce.complete(Unit) + pushed.join() + manual.join() + assertEquals(listOf("listed:1", "published:1", "announced:1", "listed:2", "published:2", "announced:2", "listed:3", "published:3", "announced:3"), order) + c.stop() + } + + @Test + fun `a cancelled queued listing never runs and releases its connection ownership`() = runBlocking { + val c = Computer(this) + val first = CompletableDeferred() + val release = CompletableDeferred() + c.work = { first.complete(Unit); release.await() } + c.poll.start(Trigger.AUTO) + first.await() + val queued = launch { c.serialRefresh(Trigger.USER) } + yield() + queued.cancel() + queued.join() + c.stop() + release.complete(Unit) + assertEquals(Trigger.AUTO, c.next()) + assertTrue(c.requests.tryReceive().isFailure) + assertEquals(1, c.closes) + } + + @Test + fun `failed serialized work releases the lock and a background job close is not lost`() = runBlocking { + val c = Computer(this) + val entered = CompletableDeferred() + val release = CompletableDeferred() + val passive = launch { c.poll.serial { entered.complete(Unit); release.await() } } + entered.await() + c.users.hold({ c.closes++ }) { } // the last background request finishes before passive work + assertEquals(0, c.closes) + release.complete(Unit) + passive.join() + assertEquals(1, c.closes) + try { c.poll.serial { error("failed list") } } catch (_: IllegalStateException) { } + assertEquals("next", c.poll.serial { "next" }) + } + + @Test + fun `removing one host cancels only its loop while another host keeps its tick`() = runBlocking { + val a = Computer(this) + val b = Computer(this) + a.poll.start(Trigger.AUTO) + b.poll.start(Trigger.AUTO) + a.next(); b.next() + a.stop() + assertEquals(1, a.closes) + assertEquals(0, b.closes) + b.ticks.send(Unit) + assertEquals(Trigger.AUTO, b.next()) + val added = Computer(this) + added.poll.start(Trigger.AUTO) + assertEquals(Trigger.AUTO, added.next()) + assertTrue(b.users.watched) + b.stop(); added.stop() + } + + @Test + fun `automatic merged ticks preserve denied and timed-out approvals until explicit Try again`() = runBlocking { + for (failure in listOf(RelayApprovalRefusedException(), RelayApprovalTimeoutException())) { + val gate = RelayApprovalGate({ false }, { _, _ -> }) + gate.onFailed("host", failure) + val c = Computer(this) + val decisions = mutableListOf() + c.work = { decisions += gate.decide("host", it) } + c.poll.start(Trigger.AUTO) + c.next(); yield() + repeat(2) { c.ticks.send(Unit); c.next(); yield() } + c.poll.changed()!!.join() + assertEquals(4, decisions.size) + assertTrue(decisions.all { it is RelayApprovalGate.Decision.Skip }) + assertTrue(gate.hold("host") != null) + c.serialRefresh(Trigger.USER) + assertTrue(decisions.last() is RelayApprovalGate.Decision.Dial) + assertEquals(null, gate.hold("host")) + c.stop() + } + } + + @Test + fun `STOP cancels a delayed reconnect and unwatched changes never dial`() = runBlocking { + val c = Computer(this) + c.poll.start(Trigger.AUTO) + c.next(); yield() + val waiting = CompletableDeferred() + val release = CompletableDeferred() + val changed = c.poll.changed { waiting.complete(Unit); release.await() }!! + waiting.await() + c.stop() + assertTrue(changed.isCancelled) + release.complete(Unit) + yield() + assertTrue(c.requests.tryReceive().isFailure, "delayed reconnect reopened a hidden computer") + assertEquals(null, c.poll.changed(), "unwatched pushes must not launch refresh jobs") + assertEquals(1, c.closes) + } + + @Test + fun `STOP cancels both in-flight and mutex-queued pushed listings`() = runBlocking { + val c = Computer(this) + c.poll.start(Trigger.AUTO) + c.next(); yield() + val listing = CompletableDeferred() + val release = CompletableDeferred() + var published = false + c.work = { listing.complete(Unit); release.await(); published = true } + val first = c.poll.changed()!! + listing.await() + val queued = c.poll.changed()!! + yield() + c.stop() + assertTrue(first.isCancelled) + assertTrue(queued.isCancelled) + release.complete(Unit) + yield() + assertFalse(published) + assertEquals(Trigger.AUTO, c.next()) + assertTrue(c.requests.tryReceive().isFailure, "a queued push survived STOP") + assertEquals(1, c.closes) + } + + @Test + fun `a delayed old watch cannot refresh after a new watch starts and another watcher retains changes`() = runBlocking { + val c = Computer(this) + c.poll.start(Trigger.AUTO) + c.next(); yield() + val delayed = CompletableDeferred() + val release = CompletableDeferred() + val oldChange = c.poll.changed { delayed.complete(Unit); withContext(NonCancellable) { release.await() } }!! + delayed.await() + try { + val stopped = c.poll.stop(closeWhenUnused = true) + c.poll.start() + assertEquals(Trigger.USER, c.next()) + release.complete(Unit) + stopped?.join() + assertTrue(oldChange.isCancelled) + yield() + assertTrue(c.requests.tryReceive().isFailure, "old reconnect was adopted by the new watcher") + c.poll.start(Trigger.AUTO) // a second visible screen + assertEquals(null, c.poll.stop(closeWhenUnused = true)) + c.poll.changed()!!.join() + assertEquals(Trigger.AUTO, c.next(), "remaining visible screen lost pushed changes") + c.stop(close = false) + assertEquals(1, c.closes, "new screen lost quick return to an old deferred close") + assertTrue(c.requests.tryReceive().isFailure, "another watcher spawned an extra loop or repeated a pushed listing") + } finally { release.complete(Unit) } + } + + @Test + fun `a replaced connection cannot deliver a stale queued push or reconnect`() = runBlocking { + val c = Computer(this) + c.poll.start(Trigger.AUTO) + c.next(); yield() + val delayEntered = CompletableDeferred() + val release = CompletableDeferred() + var current = true + val old = c.poll.changed(stillCurrent = { current }) { delayEntered.complete(Unit); release.await() }!! + delayEntered.await() + current = false // a different connection was installed meanwhile + release.complete(Unit) + old.join() + assertTrue(c.requests.tryReceive().isFailure) + c.stop() + } + + @Test + fun `app owns watchers per STARTED host and serializes the whole real listing`() { + val all = AppSourcePins.ui("AllComputersScreen.kt") + AppSourcePins.assertInOrder(all, "for (hostId in hostIds) key(hostId)", "LifecycleStartEffect(hostId)", "val starting = uiScope.launch", "yield()", "session.startWatching(Trigger.AUTO)", "watching = true", "onStopOrDispose", "starting.cancel()", "if (watching) session.stopWatching(closeWhenUnused = true)") + val inbox = AppSourcePins.blockAfter(all, "private fun AllInbox(") + AppSourcePins.assertInOrder(inbox, "LifecycleStartEffect(hostIds)", ".onScreen.showInbox()", "onStopOrDispose", "showing.forEach { it.close() }") + val host = AppSourcePins.ui("HostScreen.kt") + assertTrue(host.contains("session.startWatching()"), "ordinary host opening must retain its USER default") + val connections = AppSourcePins.app("conn/ConnectionManager.kt") + assertTrue(connections.contains("private val foreground = ForegroundRefresh(")) + assertTrue(connections.contains("refresh = { trigger -> refreshNow(trigger) }")) + assertTrue(connections.contains("pause = { delay(POLL_MS) }")) + assertTrue(connections.contains("const val POLL_MS = 8_000L")) + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(connections, "suspend fun refreshNow("), "foreground.serial {", "ensureConnected(trigger)", "c.listProjects()", "_snapshot.value = it", "graph.announce(hostId, listed, onScreen.now(), quiet = reachableQuietly())") + assertTrue(AppSourcePins.blockAfter(connections, "fun startWatching(").contains("foreground.start(initialTrigger)")) + assertTrue(AppSourcePins.blockAfter(connections, "fun stopWatching(").contains("foreground.stop(closeWhenUnused)")) + val adopt = AppSourcePins.blockAfter(connections, "private fun adopt(c: HostConnection, lease: HostLifetime.Lease)") + assertTrue(adopt.contains("foreground.changed(stillCurrent = { lifetime.isCurrent(lease) && conn == null })")) + assertTrue(adopt.contains("c.setOnChanged { if (lifetime.isCurrent(lease) && conn === c && isWatched) foreground.changed(stillCurrent = { lifetime.isCurrent(lease) && conn === c }) }")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt new file mode 100644 index 0000000000..05a3cc4c1d --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/GradleCiCoverageTest.kt @@ -0,0 +1,821 @@ +package dev.nodeterm.protocol + +import java.io.File +import java.util.Properties +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Audit A69: the Gradle/Kotlin build had none of the supply-chain coverage the npm side has. This pins + * the three pieces that closed it, all in CI config nothing else checks: + * + * 1. Every CI job that runs `./gradlew` runs `gradle/actions/setup-gradle` first, with wrapper + * validation on: it checks the committed `android/gradle/wrapper/gradle-wrapper.jar` against + * Gradle's published checksums before CI executes it. And exactly one job writes the Gradle cache + * the jobs share (android.yml says why). + * 2. CodeQL analyses the Kotlin. A `java-kotlin` analysis with build mode `none` reads Java sources + * only, and everything under android/ is Kotlin, so it would scan nothing while looking like + * coverage; the Kotlin has to be compiled between CodeQL's `init` and `analyze`. The job is in + * android.yml, not security.yml: security.yml has no workflow_dispatch and runs on no feature + * branch, so a job there could first run on somebody's pull request into main (android.yml says why + * and what its path filter costs). + * 3. Dependabot watches every Gradle build under android/ exactly once: a build another build + * includes is covered by that build's entry (Dependabot follows `includeBuild`), and a second + * entry would open each of its bumps twice. Except the builds in [notWatched]. + * 4. The protocol's network and crypto stack ([networkAndCrypto]) comes in a Dependabot PR that + * carries nothing else. In one group with every androidx/Compose/AGP/Kotlin bump it could only land + * when all of them build, and an androidx release that demands a higher compileSdk than the app's + * keeps that PR red until someone fixes it by hand (review of A69). + * + * The YAML is read as text, in the block style this repo's workflows use, as [WorkflowPathFilterTest] + * does; what these readers do not understand fails rather than being guessed. + */ +class GradleCiCoverageTest { + private val androidWorkflow = File(InteropHarness.repoRoot, ".github/workflows/android.yml") + + /** The workflows whose jobs run `./gradlew`: android.yml alone, the Kotlin CodeQL job included. */ + private val workflows = listOf(androidWorkflow) + private val dependabot = File(InteropHarness.repoRoot, ".github/dependabot.yml") + private val androidSettings = File(InteropHarness.repoRoot, "android/settings.gradle.kts") + + /** Gradle builds under android/ that Dependabot deliberately does not watch, with why. */ + private val notWatched = mapOf( + "android/tools/typecheck" to "the offline type-check is not run by CI, and its pins mirror or stand in " + + "for the app's, so they move by hand with the app's (see .github/dependabot.yml)", + ) + + /** + * The libraries the phone's connections and keys rest on, as Dependabot names them, with what each + * is for. Their bumps must not wait on an unrelated one (item 4 above). + */ + private val networkAndCrypto = mapOf( + "com.squareup.okhttp3:okhttp" to "the relay socket", + "com.hierynomus:sshj" to "the direct-SSH transport", + "net.i2p.crypto:eddsa" to "the phone's Ed25519 identity", + "org.bouncycastle:bcprov-jdk18on" to "the full BouncyCastle provider sshj needs on Android", + ) + + @Test + fun `every job that runs gradlew validates the wrapper first`() { + var gradleJobs = 0 + for (file in workflows) { + for ((id, job) in jobs(read(file))) { + val steps = steps(job) + val firstGradlew = steps.indexOfFirst(::runsGradlew) + if (firstGradlew < 0) continue + gradleJobs++ + val setup = steps.indexOfFirst(::isSetupGradle) + assertTrue( + setup in 0 until firstGradlew, + "${file.name} job `$id` runs ./gradlew without a gradle/actions/setup-gradle step before it. That " + + "step validates android/gradle/wrapper/gradle-wrapper.jar, a committed binary CI is about " + + "to execute (audit A69)." + ) + assertTrue( + steps[setup].none { Regex("""^validate-wrappers:\s*['"]?false""").containsMatchIn(it) }, + "${file.name} job `$id` turns setup-gradle's wrapper validation off" + ) + } + } + // android.yml's protocol, app, app-release and CodeQL (Kotlin) jobs. + assertTrue(gradleJobs >= 4, "found only $gradleJobs jobs running ./gradlew; has the reader stopped seeing them?") + } + + @Test + fun `exactly one job writes the Gradle cache the jobs share`() { + // The basic provider's key is the same for every job (a hash of the build files), and the first + // job to save a key keeps it: with several writers, whichever finished first decided what was + // cached. The enhanced provider keys per job, so the rule applies to basic steps only. + val basic = workflows.flatMap { file -> + jobs(read(file)).flatMap { (id, job) -> + steps(job).filter { isSetupGradle(it) && value(it, "cache-provider") == "basic" }.map { "${file.name}:$id" to it } + } + } + if (basic.isEmpty()) return + val writers = basic.filter { (_, step) -> value(step, "cache-read-only") != "true" && value(step, "cache-disabled") != "true" } + assertEquals( + 1, writers.size, + "setup-gradle steps with cache-provider: basic that write the shared cache: ${writers.map { it.first }}; " + + "exactly one may (the others set cache-read-only: true; android.yml says why)" + ) + } + + @Test + fun `private beta checks require the beta branch or opt in and tested unsigned release inputs`() { + betaPolicy(read(androidWorkflow)) + } + + @Test + fun `private beta checks execute the composed input host regressions`() { + composedInputCoverage(read(androidWorkflow)) + } + + @Test + fun `private beta checks execute every native history regression`() { + historyScrollCoverage(read(androidWorkflow)) + } + + @Test + fun `the history coverage reader rejects removal of each distinct regression`() { + val yaml = read(androidWorkflow) + assertEquals(8, historyScrollPaths.size, "Every distinct history layer has a mandatory regression") + assertEquals(8, historyScrollPaths.toSet().size) + historyScrollCoverage(yaml) + for (path in historyScrollPaths) { + val changed = yaml.lines().filterNot { it.trim() == path }.joinToString("\n") + assertTrue(changed != yaml, "the control must actually contain $path") + assertFailsWith(path) { historyScrollCoverage(changed) } + } + historyScrollCoverage(yaml) + } + + @Test + fun `the release shrinker supports the declared Kotlin compiler and Gradle wrapper`() { + androidToolchain( + read(File(InteropHarness.repoRoot, "android/build.gradle.kts")), + read(File(InteropHarness.repoRoot, "android/gradle/wrapper/gradle-wrapper.properties")), + ) + } + + @Test + fun `CodeQL compiles and analyses the app's and the protocol module's Kotlin`() { + val kotlinJobs = jobs(read(androidWorkflow)).filter { (_, job) -> + steps(job).any { isCodeqlInit(it) && "java-kotlin" in value(it, "languages").orEmpty() } + } + assertEquals( + 1, kotlinJobs.size, + "android.yml must have one CodeQL job for java-kotlin (audit A69); it lives there, where a feature " + + "branch can run it, not in security.yml" + ) + val (id, job) = kotlinJobs.entries.single() + val steps = steps(job) + val init = steps.indexOfFirst(::isCodeqlInit) + val analyze = steps.indexOfFirst { uses(it)?.startsWith("github/codeql-action/analyze@") == true } + assertTrue(analyze > init, "job `$id`: no github/codeql-action/analyze after init") + val mode = value(steps[init], "build-mode") + assertTrue( + mode == "manual" || mode == "autobuild", + "job `$id`: build-mode is ${mode ?: "not set"}; for java-kotlin only a build (manual or autobuild) " + + "extracts Kotlin, and android/ holds no Java to read without one" + ) + assertTrue( + job.any { it.trim().replace(" ", "") == "security-events:write" }, + "job `$id` needs security-events: write to upload its results" + ) + if (mode == "manual") { + val build = steps.subList(init + 1, analyze).filter(::runsGradlew) + assertTrue(build.isNotEmpty(), "job `$id`: build-mode manual, but no ./gradlew step between init and analyze") + val command = build.joinToString(" ") { it.joinToString(" ") } + assertTrue(Regex("""compile\w*Kotlin""").containsMatchIn(command), "job `$id` does not compile Kotlin: $command") + // The app is compiled from android/ (the root build); the protocol module comes with it as an + // included build, or has to be named on its own. + val app = Regex(""":app:compile\w*Kotlin""").containsMatchIn(command) + val protocolIncluded = "includeBuild(\"protocol\")" in stripComments(read(androidSettings)) + assertTrue(app, "job `$id` does not compile the app (its Keystore and WebView code): $command") + assertTrue( + protocolIncluded || "-p protocol" in command, + "job `$id` compiles the app, but android/settings.gradle.kts no longer includes the protocol " + + "build, so the protocol module's Kotlin is not analysed: $command" + ) + } + } + + @Test + fun `Dependabot watches each Gradle build under android once`() { + val root = InteropHarness.repoRoot + val androidDir = androidSettings.parentFile + val builds = androidDir.walkTopDown() + .onEnter { it.name != "build" && it.name != ".gradle" && it.name != "node_modules" } + .filter { it.isFile && it.name.startsWith("settings.gradle") } + .map { it.parentFile.relativeTo(root).invariantSeparatorsPath } + .toSortedSet() + assertTrue("android" in builds && "android/protocol" in builds, "found only these Gradle builds: $builds") + + val directories = gradleDirectories(read(dependabot)) + val covered = directories.flatMap { dir -> + val build = File(root, dir.removePrefix("/")) + assertTrue( + File(build, "settings.gradle.kts").isFile || File(build, "build.gradle.kts").isFile, + "Dependabot's gradle directory $dir holds no Gradle build" + ) + coveredBuilds(build).map { it.relativeTo(root).invariantSeparatorsPath } + } + for (build in builds) { + val times = covered.count { it == build } + val reason = notWatched[build] + if (reason != null) { + assertEquals(0, times, "Dependabot watches $build, which is deliberately not watched: $reason") + } else { + assertEquals( + 1, times, + "Dependabot's gradle entries (directories $directories) cover the Gradle build $build $times " + + "times; it must be exactly once (audit A69: none leaves it without update PRs, two open " + + "each of its bumps twice)" + ) + } + } + } + + @Test + fun `the protocol's network and crypto stack comes in a Dependabot PR of its own`() { + val root = InteropHarness.repoRoot + val entries = gradleEntries(read(dependabot)) + val scripts = gradleDirectories(read(dependabot)).flatMap { dir -> + coveredBuilds(File(root, dir.removePrefix("/"))).flatMap(::buildScripts) + } + assertTrue(scripts.size >= 3, "found only these build scripts in the watched builds: $scripts") + val declared = scripts.flatMap { declaredDependencies(read(it)) }.toSet() + for ((dependency, why) in networkAndCrypto) { + assertTrue( + dependency in declared, + "$dependency ($why) is no longer declared in a Gradle build Dependabot watches; update networkAndCrypto" + ) + } + val others = declared - networkAndCrypto.keys + assertTrue( + others.any { it.startsWith("androidx.") } && others.any { it.startsWith("com.android.") }, + "found no androidx library or Android Gradle plugin among $declared; has the reader stopped seeing them?" + ) + + for (entry in entries) { + val groups = dependabotGroups(entry) + for ((dependency, why) in networkAndCrypto) { + for (type in listOf("major", "minor", "patch")) { + val taking = groups.filter { takes(it, dependency, type) } + // No group takes it: it gets PRs of its own, which nothing else can hold back. + val group = taking.firstOrNull() ?: continue + // dependabot-core (DependencyGroupEngine, read from its source) gives a dependency several + // groups take only to the strictly most specific one, and on a tie to all of them; a run + // then skips what an earlier group, or a group whose PR is still open, already handled. + // A first group that is also strictly the most specific gets it under both rules. + val rivals = taking.drop(1).filter { specificity(it, dependency) >= specificity(group, dependency) } + assertTrue( + rivals.isEmpty(), + "a $type bump of $dependency ($why) is taken first by Dependabot group `${group.name}`, but " + + "${rivals.map { it.name }} name it at least as specifically, so which PR carries it depends " + + "on the rule Dependabot applies; keep the stack's own group first and more specific than " + + "any catch-all" + ) + val shared = others.filter { takes(group, it, type) } + assertTrue( + shared.isEmpty(), + "a $type bump of $dependency ($why) lands in Dependabot group `${group.name}` together with " + + "$shared, so it only lands when every one of those builds (an androidx release that " + + "demands a higher compileSdk than the app's keeps such a PR red); give the network and " + + "crypto stack a group of its own, listed before the catch-all" + ) + } + } + } + } + + @Test + fun `the readers find what they are asked for and refuse what they do not understand`() { + val workflow = """ + name: x + on: + push: + jobs: + # a comment + one: + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - uses: actions/checkout@v7 + # a comment naming ./gradlew + - uses: gradle/actions/setup-gradle@v6 + with: + cache-read-only: true + - name: Build + run: | + # a shell comment naming ./gradlew + ./gradlew build + two: + steps: + - name: Build ./gradlew in the name only + run: echo nothing + """.trimIndent() + val jobs = jobs(workflow) + assertEquals(listOf("one", "two"), jobs.keys.toList()) + val steps = steps(jobs.getValue("one")) + assertEquals(3, steps.size) + assertEquals(listOf(false, false, true), steps.map(::runsGradlew)) + assertEquals("true", value(steps[1], "cache-read-only")) + assertEquals(listOf(false), steps(jobs.getValue("two")).map(::runsGradlew)) + + val config = """ + version: 2 + updates: + - package-ecosystem: npm + directory: / + ignore: + - dependency-name: electron + - package-ecosystem: "gradle" + directory: '/android' + """.trimIndent() + assertEquals(listOf("/android"), gradleDirectories(config)) + assertFailsWith { + gradleDirectories("updates:\n - package-ecosystem: gradle\n directories:\n - /android\n") + } + assertEquals( + listOf("protocol", "../x"), + includedBuilds("// includeBuild(\"commented\")\nincludeBuild(\"protocol\")\n includeBuild('../x')\ninclude(\":app\")\n") + ) + + val grouped = """ + updates: + - package-ecosystem: gradle + directory: /android + schedule: + interval: weekly + groups: + # a comment + net: + patterns: + - "com.squareup.okhttp3:*" + - 'org.bouncycastle:*' + update-types: [minor, patch] + rest: + patterns: ["*"] + exclude-patterns: [androidx.*] + majors: + update-types: [major] + - package-ecosystem: npm + directory: / + """.trimIndent() + val entries = gradleEntries(grouped) + assertEquals(listOf("/android"), gradleDirectories(grouped)) + val (net, rest, majors) = dependabotGroups(entries.single()) + assertEquals(DependabotGroup("net", listOf("com.squareup.okhttp3:*", "org.bouncycastle:*"), emptyList(), listOf("minor", "patch")), net) + assertEquals(DependabotGroup("rest", listOf("*"), listOf("androidx.*"), null), rest) + assertEquals(DependabotGroup("majors", null, emptyList(), listOf("major")), majors) + assertTrue(takes(net, "com.squareup.okhttp3:okhttp", "minor")) + assertTrue(!takes(net, "com.squareup.okhttp3:okhttp", "major")) + assertTrue(!takes(rest, "androidx.core:core-ktx", "minor"), "an exclude pattern wins") + assertTrue(takes(majors, "androidx.core:core-ktx", "major"), "a group without patterns takes every dependency") + assertTrue(wildcardMatches("com.squareup.okhttp3:*", "Com.Squareup.OkHttp3:okhttp"), "matching ignores case") + assertTrue(!wildcardMatches("com.squareup.okhttp3:*", "com.squareup.okhttp3x:okhttp")) + assertTrue(!wildcardMatches("a.b", "aXb"), "only * is a wildcard") + assertEquals(1, specificity(rest, "com.squareup.okhttp3:okhttp")) + assertEquals(107, specificity(net, "com.squareup.okhttp3:okhttp")) + assertEquals(0, specificity(net, "androidx.core:core-ktx")) + assertEquals(500, specificity(majors, "androidx.core:core-ktx")) + assertEquals(1000, specificity(DependabotGroup("x", listOf("a:b"), emptyList(), null), "a:b")) + assertFailsWith { + dependabotGroups(gradleEntries(grouped.replace("update-types: [major]", "dependency-type: production")).single()) + } + assertFailsWith { + dependabotGroups(gradleEntries(grouped.replace("patterns: [\"*\"]", "patterns: \"*\"")).single()) + } + + val script = """ + plugins { + id("com.android.application") version "8.9.1" apply false + kotlin("jvm") version "2.2.0" + id("org.jetbrains.kotlin.plugin.compose") + } + dependencies { + // implementation("commented:out:1.0") + implementation(platform("androidx.compose:compose-bom:2025.06.00")) + implementation("androidx.compose.ui:ui") + api("com.squareup.okhttp3:okhttp:4.12.0") + systemProperty("x:y", "z") + inputs.file(rootDir.resolve("../app/build.gradle.kts")) + } + """.trimIndent() + assertEquals( + setOf( + "androidx.compose:compose-bom", "androidx.compose.ui:ui", "com.squareup.okhttp3:okhttp", + "com.android.application", "org.jetbrains.kotlin.jvm", + ), + declaredDependencies(script).toSet() + ) + } + + companion object { + private fun read(file: File) = file.readText().replace("\r\n", "\n") + + private fun indent(line: String) = line.length - line.trimStart().length + private fun skip(line: String) = line.isBlank() || line.trimStart().startsWith("#") + + /** + * Kotlin 2.2 metadata requires R8 8.10.21, bundled in AGP 8.10; AGP 8.9 could finish a + * release while reporting metadata parsing errors. AGP 8.10 requires Gradle 8.11.1. + * Sources: https://developer.android.com/build/kotlin-support and + * https://developer.android.com/build/releases/agp-8-10-0-release-notes + * Extend this check from the release notes when either plugin moves to another minor. + */ + internal fun androidToolchain(build: String, wrapper: String) { + fun version(raw: String): List { + assertTrue(Regex("[0-9]+\\.[0-9]+\\.[0-9]+").matches(raw), "unsupported toolchain version: $raw") + return raw.split('.').map(String::toInt) + } + fun plugin(id: String): List { + val declaration = Regex("""id\(["']${Regex.escape(id)}["']\)\s+version\s+["']([^"']+)["']""") + .findAll(stripComments(build)).toList() + assertEquals(1, declaration.size, "expected one literal version for plugin $id") + return version(declaration.single().groupValues[1]) + } + fun atLeast(actual: List, minimum: List): Boolean = actual.zip(minimum) + .firstOrNull { (a, b) -> a != b }?.let { (a, b) -> a > b } ?: true + + val kotlin = plugin("org.jetbrains.kotlin.android") + assertEquals(listOf(2, 2), kotlin.take(2), "update the R8 compatibility check for this Kotlin minor") + val agp = plugin("com.android.application") + assertTrue(atLeast(agp, listOf(8, 10, 0)), "Kotlin 2.2 requires AGP 8.10 or newer for supported R8 metadata") + assertEquals(listOf(8, 10), agp.take(2), "verify the Gradle minimum for this AGP minor and extend this check") + val properties = Properties().apply { load(wrapper.reader()) } + val distribution = properties.getProperty("distributionUrl").orEmpty() + val gradle = Regex("""/gradle-([0-9]+\.[0-9]+\.[0-9]+)-(?:bin|all)\.zip$""") + .find(distribution)?.groupValues?.get(1)?.let(::version) + ?: throw AssertionError("the wrapper must declare a stable Gradle distribution: $distribution") + assertEquals(8, gradle[0], "verify AGP 8.10 compatibility before moving to another Gradle major") + assertTrue(atLeast(gradle, listOf(8, 11, 1)), "AGP 8.10 requires Gradle 8.11.1 or newer") + } + + /** CI prepares verified release inputs; local signing never becomes a public artifact. */ + internal fun betaPolicy(yaml: String) { + val allJobs = jobs(yaml) + val beta = allJobs["beta-checks"] ?: throw AssertionError("no private beta checks job") + val header = beta.takeWhile { it.trim() != "steps:" }.map(String::trim) + fun expression(value: String?): String = value.orEmpty().removePrefix("${'$'}{{") + .removeSuffix("}}").filterNot(Char::isWhitespace) + val gate = "(github.event_name=='workflow_dispatch'&&inputs.prepare_beta)||" + + "(github.event_name=='push'&&github.ref=='refs/heads/claude/android-ios-parity-75kfem')" + assertEquals( + gate, + expression(value(header, "if")), + "private beta checks must require this beta branch's push or an opted-in manual dispatch", + ) + val needs = value(header, "needs") ?: throw AssertionError("private beta checks have no prerequisites") + assertTrue(needs.startsWith("[") && needs.endsWith("]"), "beta prerequisites must be an explicit job list") + val prerequisites = needs.removeSurrounding("[", "]").split(',').map { it.trim().removeSurrounding("'").removeSurrounding("\"") } + assertTrue( + prerequisites.containsAll(listOf("protocol", "app-release")), + "private beta checks must wait for protocol tests and the unsigned R8 release", + ) + + val lines = yaml.lines() + val env = lines.indexOfFirst { it.trimEnd() == "env:" && indent(it) == 0 } + assertTrue(env >= 0, "the beta branch needs workflow-level version defaults") + val envLines = lines.drop(env + 1).takeWhile { skip(it) || indent(it) > 0 }.map(String::trim) + val versionCode = value(envLines, "NODETERM_BETA_VERSION_CODE")?.toLongOrNull() + assertTrue(versionCode != null && versionCode in 2..2_100_000_000L, "the beta version code must exceed the initial public debug build") + val versionName = value(envLines, "NODETERM_BETA_VERSION_NAME").orEmpty() + assertTrue(Regex("[0-9]+\\.[0-9]+\\.[0-9]+-beta\\.[1-9][0-9]*").matches(versionName), "the branch's version name must identify a beta") + val dispatch = lines.indexOfFirst { it.trim() == "workflow_dispatch:" && indent(it) == 2 } + assertTrue(dispatch >= 0, "private beta inputs must be declared on workflow_dispatch") + val dispatchLines = lines.drop(dispatch + 1).takeWhile { skip(it) || indent(it) > 2 } + val prepare = dispatchLines.indexOfFirst { it.trim() == "prepare_beta:" } + assertTrue(prepare >= 0, "manual dispatch must expose the beta opt-in") + val prepareLines = dispatchLines.drop(prepare + 1).takeWhile { skip(it) || indent(it) > indent(dispatchLines[prepare]) }.map(String::trim) + assertEquals("boolean", value(prepareLines, "type"), "beta opt-in must be boolean") + assertEquals("false", value(prepareLines, "default"), "ordinary manual runs must not prepare a beta") + + val release = allJobs["app-release"] ?: throw AssertionError("no unsigned release job") + val releaseBuild = steps(release).single { runsGradlew(it) && it.any { line -> ":app:assembleRelease" in line } } + val validator = releaseBuild.indexOfFirst { "python3 tools/check-beta-version.py" in it } + val build = releaseBuild.indexOfFirst { ":app:assembleRelease" in it } + assertTrue(validator >= 0 && validator < build, "selected beta versions must pass the shared packager rules before Gradle builds") + val provenance = steps(beta).single { value(it, "BETA_VERSION_CODE") != null && value(it, "BETA_VERSION_NAME") != null } + for ((key, input, default) in listOf( + Triple("NODETERM_ANDROID_VERSION_CODE", "beta_version_code", "NODETERM_BETA_VERSION_CODE"), + Triple("NODETERM_ANDROID_VERSION_NAME", "beta_version_name", "NODETERM_BETA_VERSION_NAME"), + )) { + assertEquals( + "($gate)&&(inputs.$input||env.$default)||''", + expression(value(releaseBuild, key)), + "release version overrides must use the same beta gate and version defaults", + ) + assertEquals( + "inputs.$input||env.$default", + expression(value(provenance, key.removePrefix("NODETERM_ANDROID_").let { "BETA_$it" })), + "release provenance must use the build's version fallback", + ) + for ((id, job) in allJobs) { + if (id == "app-release") continue + assertTrue(steps(job).none { value(it, key) != null }, "job $id exports a beta release version override") + } + } + + val releaseUpload = steps(release).single { + uses(it)?.startsWith("actions/upload-artifact@") == true && value(it, "name") == "nodeterm-android-release-unsigned" + } + assertEquals("android/app/build/outputs/apk/release/*-unsigned.apk", value(releaseUpload, "path")) + assertEquals("error", value(releaseUpload, "if-no-files-found"), "a missing unsigned APK must fail the release input job") + for ((id, job) in allJobs) { + for (step in steps(job)) { + if (id in setOf("app-release", "beta-checks")) { + assertTrue(step.none { "secrets." in it }, "beta build inputs must not receive a signing secret") + } + if (uses(step)?.startsWith("actions/upload-artifact@") != true) continue + val path = value(step, "path").orEmpty() + if (".apk" !in path && "/apk" !in path) continue + // Existing public debug builds are deliberately signed with the repository key + // (A10). Beta/release artifacts must contain unsigned build inputs only. + if (id == "app" && path == "android/app/build/outputs/apk/debug/*.apk") continue + assertTrue( + id == "app-release" && path == "android/app/build/outputs/apk/release/*-unsigned.apk", + "job $id uploads an APK outside the unsigned release input contract: $path", + ) + } + } + } + + /** A128: a release candidate must run the host submission tests, including real tmux. */ + internal fun composedInputCoverage(yaml: String) { + val beta = jobs(yaml)["beta-checks"] ?: throw AssertionError("no private beta checks job") + val matching = steps(beta).filter { value(it, "name") == "Phone delivery and ack tests" } + assertEquals(1, matching.size, "private beta checks need exactly one phone delivery test step") + val step = matching.single() + val run = step.indexOfFirst { it.startsWith("run:") } + assertTrue(run >= 0 && step[run] == "run: >-", "phone delivery tests must use the understood folded command") + val arguments = step.drop(run + 1).takeWhile { !Regex("^[A-Za-z_-]+:").containsMatchIn(it) } + .flatMap { it.split(Regex("\\s+")).filter(String::isNotEmpty) } + assertTrue(arguments.all { Regex("[A-Za-z0-9./=_-]+").matches(it) }, "extend the command reader before introducing shell syntax") + assertEquals(listOf("npx", "vitest", "run"), arguments.take(3), "phone delivery checks must execute Vitest") + for (path in listOf( + "src/core/composed-tmux.test.ts", + "src/core/composed-tmux.realtmux.test.ts", + "src/core/pty-composed-input.test.ts", + "src/core/composed-pty.test.ts", + "src/core/session-host-composed.test.ts", + "src/core/native-windows-pane.test.ts", + "src/session-host/composed-input-host.test.ts", + "src/main/remote/host-composed-input.test.ts", + )) { + assertEquals(1, arguments.drop(3).count { it == path }, "private beta phone delivery checks must execute $path exactly once") + } + } + + internal val historyScrollPaths = listOf( + "src/core/history-scroll-view.test.ts", + "src/core/native-history-scroll.test.ts", + "src/core/pty-history-scroll.test.ts", + "src/core/session-host-history-scroll.test.ts", + "src/session-host/terminal-scroll.test.ts", + "src/session-host/history-scroll-host.test.ts", + "src/main/remote/host-history-scroll.test.ts", + "src/main/remote/android-history-fixture.test.ts", + ) + + /** A129: all backend, snapshot and public relay layers execute in the private beta gate. */ + internal fun historyScrollCoverage(yaml: String) { + val beta = jobs(yaml)["beta-checks"] ?: throw AssertionError("no private beta checks job") + val step = steps(beta).single { value(it, "name") == "Phone delivery and ack tests" } + val run = step.indexOfFirst { it.startsWith("run:") } + assertTrue(run >= 0 && step[run] == "run: >-", "history tests require the understood folded command") + val arguments = step.drop(run + 1).takeWhile { !Regex("^[A-Za-z_-]+:").containsMatchIn(it) } + .flatMap { it.split(Regex("\\s+")).filter(String::isNotEmpty) } + assertTrue(arguments.all { Regex("[A-Za-z0-9./=_-]+").matches(it) }, "extend the reader before adding shell syntax") + assertEquals(listOf("npx", "vitest", "run"), arguments.take(3)) + for (path in historyScrollPaths) + assertEquals(1, arguments.drop(3).count { it == path }, "private beta history checks must execute $path exactly once") + } + + /** The top-level `jobs:` of a workflow, as each job's non-comment lines (its id line excluded). */ + internal fun jobs(yaml: String): Map> { + val lines = yaml.lines() + val start = lines.indexOfFirst { it.trimEnd() == "jobs:" } + if (start < 0) throw AssertionError("no top-level `jobs:`") + val out = LinkedHashMap>() + var jobIndent = -1 + for (line in lines.drop(start + 1)) { + if (skip(line)) continue + val ind = indent(line) + if (ind == 0) break + if (jobIndent < 0) jobIndent = ind + if (ind == jobIndent) { + val id = Regex("""([A-Za-z0-9_-]+):""").matchEntire(line.trim())?.groupValues?.get(1) + ?: throw AssertionError("unexpected line in jobs: $line") + out[id] = ArrayList() + } else if (ind > jobIndent) { + out.values.last().add(line) + } else { + throw AssertionError("unexpected indentation in jobs: $line") + } + } + return out + } + + /** A job's steps, each as its trimmed lines (the first without its `- `). */ + internal fun steps(job: List): List> { + val s = job.indexOfFirst { it.trim() == "steps:" } + if (s < 0) return emptyList() + val stepsIndent = indent(job[s]) + val out = ArrayList>() + var itemIndent = -1 + for (line in job.drop(s + 1)) { + if (skip(line)) continue + val ind = indent(line) + if (ind <= stepsIndent) break + if (itemIndent < 0) itemIndent = ind + val text = line.trim() + if (ind == itemIndent) { + if (!text.startsWith("- ")) throw AssertionError("unexpected line in steps: $line") + out.add(arrayListOf(text.removePrefix("- ").trim())) + } else if (ind > itemIndent) { + out.last().add(text) + } else { + throw AssertionError("unexpected indentation in steps: $line") + } + } + return out + } + + /** The value of `key:` among a step's (or an update entry's) lines, unquoted; null when absent. */ + internal fun value(lines: List, key: String): String? = + lines.firstOrNull { it.startsWith("$key:") }?.removePrefix("$key:")?.trim()?.removeSurrounding("'")?.removeSurrounding("\"") + + private fun uses(step: List) = value(step, "uses") + private fun isSetupGradle(step: List) = uses(step)?.startsWith("gradle/actions/setup-gradle@") == true + private fun isCodeqlInit(step: List) = uses(step)?.startsWith("github/codeql-action/init@") == true + + /** Whether a step's `run:` invokes the wrapper (a step's name does not count). */ + internal fun runsGradlew(step: List): Boolean { + val run = step.indexOfFirst { it.startsWith("run:") } + return run >= 0 && step.drop(run).any { "gradlew" in it } + } + + /** + * The `updates:` entries of a dependabot.yml, each as its non-comment lines: the first trimmed and + * without its `- `, the others with their indentation (the nesting of `groups:` needs it). + */ + private fun updateEntries(yaml: String): List> { + val lines = yaml.lines().filterNot(::skip) + val start = lines.indexOfFirst { it.trimEnd() == "updates:" } + if (start < 0) throw AssertionError("no top-level `updates:`") + val entries = ArrayList>() + var itemIndent = -1 + for (line in lines.drop(start + 1)) { + val ind = indent(line) + if (ind == 0) break + if (itemIndent < 0) itemIndent = ind + val text = line.trim() + if (ind == itemIndent) { + if (!text.startsWith("- ")) throw AssertionError("unexpected line in updates: $line") + entries.add(arrayListOf(text.removePrefix("- ").trim())) + } else if (ind > itemIndent) { + entries.last().add(line) + } else { + throw AssertionError("unexpected indentation in updates: $line") + } + } + return entries + } + + /** The `package-ecosystem: gradle` entries of a dependabot.yml, as [updateEntries] gives them. */ + internal fun gradleEntries(yaml: String): List> = + updateEntries(yaml).filter { value(it.map(String::trim), "package-ecosystem") == "gradle" }.onEach { entry -> + if (entry.any { it.trim().startsWith("directories:") }) { + throw AssertionError("`directories:` is not understood by this reader; extend it") + } + } + + /** The `directory:` of each `package-ecosystem: gradle` entry of a dependabot.yml. */ + internal fun gradleDirectories(yaml: String): List = gradleEntries(yaml).map { entry -> + value(entry.map(String::trim), "directory") ?: throw AssertionError("a gradle entry without a directory: $entry") + } + + /** + * One Dependabot group, as far as it decides what it takes: [patterns] null when the group has none + * (it takes every dependency), [updateTypes] null when it takes every update type. + */ + internal data class DependabotGroup( + val name: String, + val patterns: List?, + val excludePatterns: List, + val updateTypes: List?, + ) + + /** The `groups:` of one update entry (from [gradleEntries]), in file order. */ + internal fun dependabotGroups(entry: List): List { + val at = entry.indexOfFirst { it.trim() == "groups:" } + if (at < 0) return emptyList() + if (at == 0) throw AssertionError("an update entry that starts with `groups:` is not understood by this reader") + val block = entry.drop(at + 1).takeWhile { indent(it) > indent(entry[at]) } + val groups = ArrayList>>() + var groupIndent = -1 + for (line in block) { + val ind = indent(line) + if (groupIndent < 0) groupIndent = ind + if (ind == groupIndent) { + val name = Regex("""([A-Za-z0-9_.-]+):""").matchEntire(line.trim())?.groupValues?.get(1) + ?: throw AssertionError("unexpected line in groups: $line") + groups.add(name to ArrayList()) + } else if (ind > groupIndent) { + groups.last().second.add(line) + } else { + throw AssertionError("unexpected indentation in groups: $line") + } + } + return groups.map { (name, body) -> group(name, body) } + } + + private fun group(name: String, body: List): DependabotGroup { + val rules = HashMap>() + var keyIndent = -1 + var i = 0 + while (i < body.size) { + val line = body[i++] + if (keyIndent < 0) keyIndent = indent(line) + if (indent(line) != keyIndent) throw AssertionError("unexpected indentation in group $name: $line") + val (key, rest) = Regex("""([a-z-]+):\s*(.*)""").matchEntire(line.trim())?.destructured + ?: throw AssertionError("unexpected line in group $name: $line") + if (key !in setOf("patterns", "exclude-patterns", "update-types")) { + // dependency-type, applies-to, group-by... change what a group takes; extend this reader. + throw AssertionError("`$key` in Dependabot group $name is not understood by this reader; extend it") + } + val items = if (rest.isNotEmpty()) { + if (!rest.startsWith("[") || !rest.endsWith("]")) throw AssertionError("group $name: `$key` is not a list: $rest") + rest.removeSurrounding("[", "]").split(",").map(String::trim).filter(String::isNotEmpty) + } else { + val block = ArrayList() + while (i < body.size && indent(body[i]) > keyIndent) { + val item = body[i++].trim() + if (!item.startsWith("- ")) throw AssertionError("unexpected line in group $name: $item") + block.add(item.removePrefix("- ").trim()) + } + block + } + rules[key] = items.map { item -> + if ('#' in item) throw AssertionError("group $name: a comment inside `$key` is not understood: $item") + item.removeSurrounding("'").removeSurrounding("\"") + } + } + return DependabotGroup(name, rules["patterns"], rules["exclude-patterns"].orEmpty(), rules["update-types"]) + } + + /** dependabot-core's WildcardMatcher: `*` matches any run of characters, the rest itself, ignoring case. */ + internal fun wildcardMatches(pattern: String, name: String): Boolean = + Regex(pattern.lowercase().split("*").joinToString(".*") { Regex.escape(it) }).matches(name.lowercase()) + + /** Whether [group] takes an [updateType] update of [name] (dependabot-core's DependencyGroup#contains?). */ + internal fun takes(group: DependabotGroup, name: String, updateType: String): Boolean = + (group.updateTypes == null || updateType in group.updateTypes) && + group.excludePatterns.none { wildcardMatches(it, name) } && + (group.patterns == null || group.patterns.any { wildcardMatches(it, name) }) + + /** + * How specifically [group] names [name], by dependabot-core's PatternSpecificityCalculator: an exact + * pattern 1000, one without `*` 500, no patterns 500, `*` alone 1, other wildcards + * 100 - 10 per `*` + one per character past five. + */ + internal fun specificity(group: DependabotGroup, name: String): Int { + val patterns = group.patterns ?: return 500 + return patterns.filter { wildcardMatches(it, name) }.maxOfOrNull { p -> + when { + p == name -> 1000 + p == "*" -> 1 + '*' !in p -> 500 + else -> maxOf(100 - 10 * p.count { it == '*' } + maxOf(p.length - 5, 0), 1) + } + } ?: 0 + } + + /** The build scripts of one Gradle build: its own and its subprojects', not those of builds inside it. */ + private fun buildScripts(build: File): List = build.canonicalFile.let { dir -> + dir.walkTopDown() + .onEnter { + it == dir || (it.name !in setOf("build", ".gradle", "node_modules") && + listOf("settings.gradle.kts", "settings.gradle").none { s -> File(it, s).isFile }) + } + .filter { it.isFile && (it.name == "build.gradle.kts" || it.name == "build.gradle") } + .toList() + } + + /** + * The dependencies a build script declares, named as Dependabot names them: `group:artifact` for a + * library (from any call with one "group:artifact[:version]" argument, `platform(...)` included) + * and the plugin id for a plugin with a version (`kotlin("jvm")` is org.jetbrains.kotlin.jvm). + */ + internal fun declaredDependencies(script: String): List { + val text = stripComments(script) + val libraries = Regex("""\b\w+\(\s*"([A-Za-z0-9_.-]+):([A-Za-z0-9_.-]+)(?::[A-Za-z0-9_.+-]*)?"\s*\)""") + .findAll(text).map { "${it.groupValues[1]}:${it.groupValues[2]}" } + val plugins = Regex("""\bid\(\s*"([^"]+)"\s*\)\s+version\s+"""").findAll(text).map { it.groupValues[1] } + val kotlinPlugins = Regex("""\bkotlin\(\s*"([^"]+)"\s*\)\s+version\s+"""").findAll(text) + .map { "org.jetbrains.kotlin.${it.groupValues[1]}" } + return (libraries + plugins + kotlinPlugins).toList() + } + + private fun stripComments(kts: String) = + kts.replace(Regex("""(?s)/\*.*?\*/"""), "").lines().joinToString("\n") { it.replace(Regex("""(^|\s)//.*$"""), "") } + + /** The builds a settings script includes with `includeBuild(...)`, as written (Dependabot's reading). */ + internal fun includedBuilds(settings: String): List = + Regex("""(?:^|\s)includeBuild\s*\(\s*["']([^"']+)["']\s*\)""").findAll(stripComments(settings)).map { it.groupValues[1] }.toList() + + /** [build] and every build it includes, transitively: what one Dependabot gradle entry covers. */ + private fun coveredBuilds(build: File, seen: MutableSet = HashSet()): List { + val dir = build.canonicalFile + if (!seen.add(dir)) return emptyList() + val settings = listOf("settings.gradle.kts", "settings.gradle").map { File(dir, it) }.firstOrNull { it.isFile } + ?: return listOf(dir) + return listOf(dir) + includedBuilds(read(settings)).flatMap { coveredBuilds(File(dir, it), seen) } + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HeldQuickActionsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HeldQuickActionsTest.kt new file mode 100644 index 0000000000..902c4b604c --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HeldQuickActionsTest.kt @@ -0,0 +1,139 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.LabelEditResult +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.NewSessionHint +import dev.nodeterm.protocol.host.QuickActions +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.AgentNodeStatus +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.MirrorInbox +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonElement +import kotlin.test.Test +import dev.nodeterm.protocol.model.* +import kotlin.test.assertEquals + +class HeldQuickActionsTest { + private val node = "term-1" + private val ticket = "term-1-1700000000000-42" + private val questions = listOf(HookQuestion("Which?", "Choice", listOf(HookQuestionOption("A", ""), HookQuestionOption("B", "")), true)) + private fun card(kind: InboxKind = InboxKind.QUESTION) = InboxEvent( + "e1", 1, node, "claude", "s", kind, "Which?", null, false, false, + options = listOf("unsafe legacy choice"), multiSelect = false, pendingId = ticket.takeIf { kind == InboxKind.APPROVAL }, + permissionSuggestions = listOf(PermissionSuggestion(0, "Bash(npm test) — session")), + questionPendingId = ticket.takeIf { kind == InboxKind.QUESTION }, questions = questions) + private fun snapshot(vararg events: InboxEvent) = ProjectsSnapshot(emptyList(), emptySet(), AgentStatusFile( + 1, mapOf(node to AgentNodeStatus(AgentState.WAITING, "claude", "s", null, false, 1, null)), null, null, MirrorInbox(events.toList(), emptyMap()), null), 1) + private class FakeConn( + vararg snapshots: ProjectsSnapshot, + private val outcome: ApprovalOutcome = ApprovalOutcome.SENT, + answerApprovals: Boolean = true + ) : HostConnection { + private val queue = ArrayDeque(snapshots.toList()) + val answered = mutableListOf>() + val keys = mutableListOf() + val structured = mutableListOf() + + override val kind = TransportKind.RELAY + override val capabilities = HostCapabilities( + boardWrites = true, git = true, nodeActions = true, registerNode = true, answerApprovals = answerApprovals + ) + + override suspend fun listProjects(): ProjectsSnapshot = if (queue.size > 1) queue.removeFirst() else queue.first() + override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome { + answered += event.pendingId to allow + return outcome + } + override suspend fun rememberApproval(event: InboxEvent, suggestionIndex: Int): ApprovalOutcome { + structured += "remember:$suggestionIndex"; return outcome + } + override suspend fun answerQuestions(event: InboxEvent, selections: List>): ApprovalOutcome { + structured += "questions:$selections"; return outcome + } + override suspend fun sendKeys(nodeId: String, keys: String) { + this.keys += keys + } + + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream = + error("unused") + override suspend fun wake(nodeId: String) { error("unused") } + override suspend fun refresh(nodeId: String) { error("unused") } + override suspend fun rename(nodeId: String, title: String) { error("unused") } + override suspend fun ensureBoard(projectId: String): List? = error("unused") + override suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean = error("unused") + override suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? = error("unused") + override suspend fun registerNode(projectId: String, node: NewNode): Boolean = error("unused") + override suspend fun ackRead(nodeId: String, eventId: String?) { error("unused") } + override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? = error("unused") + override fun setOnChanged(listener: (() -> Unit)?) {} + override fun setOnClosed(listener: ((String?) -> Unit)?) {} + override fun close() {} + } + + + @Test fun `held multi-select uses one structured call and never keys even with legacy options present`() = runBlocking { + val event = card(); val conn = FakeConn(snapshot(event)) + assertEquals(QuickActions.Result.SENT, QuickActions.answerQuestions(conn, event, listOf(listOf(0, 1)))) + assertEquals(listOf("questions:[[0, 1]]"), conn.structured); assertEquals(emptyList(), conn.keys) + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerQuestion(conn, event, 0)) + assertEquals(emptyList(), conn.keys) + } + @Test fun `remember uses only advertised exact index and does not require parent blocked badge`() = runBlocking { + val event = card(InboxKind.APPROVAL); val conn = FakeConn(snapshot(event)) + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.rememberApproval(conn, event, 1)) + assertEquals(QuickActions.Result.SENT, QuickActions.rememberApproval(conn, event, 0)) + assertEquals(listOf("remember:0"), conn.structured); assertEquals(emptyList(), conn.keys) + } + @Test fun `disappearing ticket or changed schema does not report success or write anything`() = runBlocking { + val event = card() + for (fresh in listOf(snapshot(), snapshot(event.copy(questionPendingId = "new")), snapshot(event.copy(questions = emptyList())))) { + val conn = FakeConn(fresh) + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerQuestions(conn, event, listOf(listOf(0)))) + assertEquals(emptyList(), conn.structured); assertEquals(emptyList(), conn.keys) + } + val approval = card(InboxKind.APPROVAL) + val changed = FakeConn(snapshot(approval.copy(permissionSuggestions = listOf(PermissionSuggestion(0, "different"))))) + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.rememberApproval(changed, approval, 0)) + assertEquals(emptyList(), changed.structured) + } + @Test fun `expired hold opens the session while independently settled card is already handled`() = runBlocking { + val event = card() + val open = FakeConn(snapshot(event), outcome = ApprovalOutcome.GONE) + assertEquals(QuickActions.Result.EXPIRED, QuickActions.answerQuestions(open, event, listOf(listOf(0)))) + val settled = FakeConn(snapshot(event), snapshot(event.copy(resolved = true)), outcome = ApprovalOutcome.GONE) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerQuestions(settled, event, listOf(listOf(0)))) + val missingAfter = FakeConn(snapshot(event), snapshot(), outcome = ApprovalOutcome.GONE) + assertEquals(QuickActions.Result.EXPIRED, QuickActions.answerQuestions(missingAfter, event, listOf(listOf(0)))) + assertEquals(emptyList(), missingAfter.keys) + } + @Test fun `older unsupported ambiguous or resolved responses never fall back to keystrokes`() = runBlocking { + val event = card() + for (outcome in listOf(ApprovalOutcome.UNSUPPORTED, ApprovalOutcome.ALREADY_HANDLED)) { + val conn = FakeConn(snapshot(event), outcome = outcome) + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerQuestions(conn, event, listOf(listOf(0)))) + assertEquals(emptyList(), conn.keys) + } + val resolved = FakeConn(snapshot(event.copy(resolved = true))) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerQuestions(resolved, event, listOf(listOf(0)))) + assertEquals(emptyList(), resolved.structured) + } + @Test fun `incomplete or duplicate selections cannot reach transport`() = runBlocking { + val event = card(); val conn = FakeConn(snapshot(event)) + for (selection in listOf(emptyList(), listOf(emptyList()), listOf(listOf(0, 0)), listOf(listOf(2)))) + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerQuestions(conn, event, selection)) + assertEquals(emptyList(), conn.structured); assertEquals(emptyList(), conn.keys) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HookRepliesTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HookRepliesTest.kt new file mode 100644 index 0000000000..ab5b71e3a3 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HookRepliesTest.kt @@ -0,0 +1,83 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.* +import kotlinx.serialization.json.* +import kotlin.test.* + +class HookRepliesTest { + private val j = Json + private val permission = j.parseToJsonElement("""{"hook_event_name":"PermissionRequest","tool_name":"Bash","permission_suggestions":[{"type":"addRules","behavior":"allow","destination":"localSettings","rules":[{"toolName":"Bash","ruleContent":"npm test"}]}]}""").jsonObject + private val question = j.parseToJsonElement("""{"hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"extra":"preserved","questions":[{"question":"Which?","header":"Pick","multiSelect":true,"options":[{"label":"α","description":"First"},{"label":"β","description":"Second"}]},{"question":"__proto__","header":"Other","options":[{"label":"yes","description":""},{"label":"no","description":""}]}]}}""").jsonObject + private fun output(text: String?) = j.parseToJsonElement(assertNotNull(text).substringAfter('\n')).jsonObject["hookSpecificOutput"]!!.jsonObject + private fun permissionWith(update: JsonElement) = JsonObject(permission + ("permission_suggestions" to JsonArray(listOf(update)))) + private fun update(changes: Map) = JsonObject(permission["permission_suggestions"]!!.jsonArray[0].jsonObject + changes) + + @Test fun `remember echoes only exact offered concrete rule and scope`() { + val reply = assertNotNull(HookReplies.remember(permission, 0)) + assertTrue(reply.startsWith(HookReplies.MARKER + "\n")) + assertEquals(permission["permission_suggestions"], output(reply)["decision"]!!.jsonObject["updatedPermissions"]) + val second = update(mapOf("destination" to JsonPrimitive("session"), "rules" to JsonArray(listOf(buildJsonObject { put("toolName", "Bash"); put("ruleContent", "npm build") })))) + val several = JsonObject(permission + ("permission_suggestions" to JsonArray(listOf(permission["permission_suggestions"]!!.jsonArray[0], second)))) + assertEquals(JsonArray(listOf(second)), output(HookReplies.remember(several, 1))["decision"]!!.jsonObject["updatedPermissions"]) + for (destination in listOf("session", "projectSettings", "userSettings")) { + val p = permissionWith(update(mapOf("destination" to JsonPrimitive(destination)))) + assertEquals(destination, output(HookReplies.remember(p, 0))["decision"]!!.jsonObject["updatedPermissions"]!!.jsonArray[0].jsonObject["destination"]!!.jsonPrimitive.content) + } + } + @Test fun `never changes permission mode denies or unknown scope`() { + for (changes in listOf(mapOf("type" to JsonPrimitive("setMode")), mapOf("behavior" to JsonPrimitive("deny")), mapOf("destination" to JsonPrimitive("anywhere")))) + assertNull(HookReplies.remember(permissionWith(update(changes)), 0)) + assertNull(HookReplies.remember(permission, -1)); assertNull(HookReplies.remember(permission, 1)) + assertNull(HookReplies.remember(JsonObject(permission + ("hook_event_name" to JsonPrimitive("PreToolUse"))), 0)) + } + @Test fun `never remembers whole tool wildcard or other tool grant`() { + for (rule in listOf("""{"toolName":"Bash"}""", """{"toolName":"Bash","ruleContent":"*"}""", """{"toolName":"Write","ruleContent":"npm test"}""")) { + val p = permissionWith(update(mapOf("rules" to JsonArray(listOf(j.parseToJsonElement(rule)))))) + assertNull(HookReplies.remember(p, 0)) + } + } + @Test fun `all question answers use option labels preserve input and follow option order`() { + val out = output(HookReplies.answerQuestions(question, listOf(listOf(1, 0), listOf(1)))) + assertEquals("PreToolUse", out["hookEventName"]!!.jsonPrimitive.content) + val input = out["updatedInput"]!!.jsonObject + assertEquals(question["tool_input"]!!.jsonObject["questions"], input["questions"]) + assertEquals("preserved", input["extra"]!!.jsonPrimitive.content) + assertEquals("α, β", input["answers"]!!.jsonObject["Which?"]!!.jsonPrimitive.content) + assertEquals("no", input["answers"]!!.jsonObject["__proto__"]!!.jsonPrimitive.content) + } + @Test fun `requires complete nonduplicate valid selections for every question`() { + for (selection in listOf(emptyList(), listOf(listOf(0)), listOf(listOf(0), listOf(0), listOf(0)), listOf(emptyList(), listOf(0)), listOf(listOf(0, 0), listOf(0)), listOf(listOf(2), listOf(0)), listOf(listOf(0), listOf(0, 1)))) + assertNull(HookReplies.answerQuestions(question, selection)) + assertNull(HookReplies.answerQuestions(permission, listOf(listOf(0)))) + assertNull(HookReplies.answerQuestions(JsonObject(question + ("agent_id" to JsonPrimitive("child"))), listOf(listOf(0), listOf(0)))) + } + @Test fun `reply size is bounded in UTF8 bytes not character count`() { + val large = JsonObject(question + ("tool_input" to JsonObject(question["tool_input"]!!.jsonObject + ("extra" to JsonPrimitive("界".repeat(50000)))))) + assertNull(HookReplies.answerQuestions(large, listOf(listOf(0), listOf(0)))) + } + @Test fun `schema rejects ambiguous clipped and oversized question data`() { + val rows = question["tool_input"]!!.jsonObject["questions"]!!.jsonArray + assertNull(HookReplies.questions(JsonArray(listOf(rows[0], rows[0])))) + assertNull(HookReplies.questions(JsonArray(List(5) { rows[0] }))) + val q = rows[0].jsonObject + assertNull(HookReplies.questions(JsonArray(listOf(JsonObject(q + ("header" to JsonPrimitive("h".repeat(81)))))))) + assertNull(HookReplies.questions(JsonArray(listOf(JsonObject(q + ("multiSelect" to JsonPrimitive("true"))))))) + val repeated = JsonArray(listOf(q["options"]!!.jsonArray[0], q["options"]!!.jsonArray[0])) + assertNull(HookReplies.questions(JsonArray(listOf(JsonObject(q + ("options" to repeated)))))) + } + @Test fun `request ownership is exact and cannot traverse or select a different node`() { + assertTrue(HookReplies.belongsToNode("term-1", "term-1-1700000000000-42")) + for ((node, ticket) in listOf("term" to "term-1-1700000000000-42", "other" to "term-1-1-1", "else-1" to "term-1-1-1", "../x" to "../x-1-1", "term" to "term-1", "term" to "term-1-1.answer")) + assertFalse(HookReplies.belongsToNode(node, ticket)) + } + @Test fun `parser preserves additive capabilities but legacy held schema cannot become digit buttons`() { + val questions = question["tool_input"]!!.jsonObject["questions"]!! + val status = ProjectsParser.parseStatus("""{"nodes":{},"inbox":{"events":[{"id":"q","ts":1,"nodeId":"term-1","kind":"question","title":"Which?","questionPendingId":"term-1-1-1","questions":$questions,"options":["unsafe"]},{"id":"p","ts":1,"nodeId":"term-1","kind":"approval","title":"Approve","pendingId":"term-1-1-2","permissionSuggestions":[{"index":0,"label":"Bash(npm test) — session"}]}]}}""")!! + val q = status.inbox!!.events[0] + assertIs(QuestionChoices.of(q)) + assertEquals(2, q.questions.size) + assertEquals(listOf(PermissionSuggestion(0, "Bash(npm test) — session")), status.inbox!!.events[1].permissionSuggestions) + assertEquals(QuestionChoices.None, QuestionChoices.of(q.copy(questions = emptyList()))) + assertIs(QuestionChoices.of(q.copy(questionPendingId = null, questions = emptyList(), multiSelect = false))) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HookRepliesWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HookRepliesWiringTest.kt new file mode 100644 index 0000000000..0ada7be681 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HookRepliesWiringTest.kt @@ -0,0 +1,32 @@ +package dev.nodeterm.protocol + +import kotlin.test.Test +import kotlin.test.assertTrue +import java.io.File + +class HookRepliesWiringTest { + @Test fun `native actions use complete structured selections and explicitly selected rule scope`() { + val source = AppSourcePins.ui("InboxTab.kt") + assertTrue("ev.pendingId != null && ev.permissionSuggestions.isNotEmpty()" in source) + assertTrue("Text(suggestion.label)" in source) + assertTrue("QuickActions.rememberApproval(c, ev, index)" in source) + assertTrue("is QuestionChoices.Held ->" in source) + assertTrue("choices.questions.forEachIndexed" in source) + assertTrue("if (question.multiSelect) Checkbox" in source) + assertTrue("else RadioButton" in source) + assertTrue("!busy && selected.all { it.isNotEmpty() }" in source) + assertTrue("QuickActions.answerQuestions(c, ev, answer)" in source) + val run = AppSourcePins.blockAfter(source, "fun runOn(") + AppSourcePins.assertInOrder(run, "val actionKey = from.computer.hostId to eventId", "if (actionKey in pendingActions) return", "pendingActions = pendingActions + actionKey", "scope.launch") + assertTrue("pendingActions = pendingActions - actionKey" in run) + AppSourcePins.assertInOrder(run, "catch (e: CancellationException)", "throw e", "catch (e: Exception)") + } + @Test fun `SSH sends the bounded original-derived reply over stdin and requires confirmed exit status`() { + val source = File(InteropHarness.repoRoot, "android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt").readText() + assertTrue("HookReplies.belongsToNode(event.nodeId, ticket)" in source) + assertTrue("SshScripts.readHookRequest(event.nodeId, ticket)" in source) + assertTrue("SshScripts.answerHook(event.nodeId, ticket, checksum), stdin = reply, uncertainWrite = true, outputLimit = 1024" in source) + assertTrue("cmd.outputStream.use" in source) + assertTrue("writeCode != 0" in source) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HookReplyScriptsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HookReplyScriptsTest.kt new file mode 100644 index 0000000000..06650dc764 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HookReplyScriptsTest.kt @@ -0,0 +1,60 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.ssh.SshScripts +import dev.nodeterm.protocol.model.HookReplies +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import org.junit.jupiter.api.Assumptions.assumeTrue +import kotlin.test.* + +/** Actual SSH-visible POSIX writers, with stdin deliberately suspended across the hold's timeout. */ +class HookReplyScriptsTest { + private val node = "term-1" + private val ticket = "term-1-100000-42" + private fun process(home: File, script: String) = ProcessBuilder("/bin/sh", "-c", script).apply { + environment().clear(); environment().putAll(mapOf("HOME" to home.path, "PATH" to "/usr/bin:/bin")) + }.start() + @Test fun `suspended stdin cannot publish an answer after a held request is deleted or replaced`() { + assumeTrue(!System.getProperty("os.name").startsWith("Windows"), "SSH-visible scripts require a POSIX host shell") + val home = Files.createTempDirectory("nt-held-scripts").toFile() + val dir = File(home, ".nodeterm/pending").apply { mkdirs() } + val request = File(dir, "$ticket.json") + try { + for (remove in listOf(true, false)) { + request.writeText("{\"original\":true}") + val read = process(home, SshScripts.readHookRequest(node, ticket)) + val output = read.inputStream.bufferedReader().readText() + assertTrue(read.waitFor(5, TimeUnit.SECONDS)); assertEquals(0, read.exitValue()) + val writer = process(home, SshScripts.answerHook(node, ticket, output.substringBefore('\n'))) + try { + writer.outputStream.write((HookReplies.MARKER + "\npartial").toByteArray()); writer.outputStream.flush() + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (dir.listFiles()!!.none { it.name.contains(".answer.tmp.") }) { + assertTrue(System.nanoTime() < deadline, "writer did not begin its temp") + Thread.sleep(5) + } + if (remove) assertTrue(request.delete()) else request.writeText("{\"original\":false}") + writer.outputStream.use { it.write("remaining".toByteArray()) } + assertTrue(writer.waitFor(5, TimeUnit.SECONDS)) + assertEquals(if (remove) 0 else 2, writer.exitValue()) + assertEquals(if (remove) "gone" else "", writer.inputStream.bufferedReader().readText().trim()) + assertFalse(File(dir, "$ticket.answer").exists()) + assertTrue(dir.listFiles()!!.none { it.name.contains(".tmp.") }) + } finally { writer.destroyForcibly() } + } + } finally { home.deleteRecursively() } + } + @Test fun `oversized held JSON is refused before it can be returned to the client`() { + assumeTrue(!System.getProperty("os.name").startsWith("Windows"), "SSH-visible scripts require a POSIX host shell") + val home = Files.createTempDirectory("nt-held-big").toFile() + try { + File(home, ".nodeterm/pending").mkdirs() + File(home, ".nodeterm/pending/$ticket.json").writeText("x".repeat(HookReplies.MAX_BYTES + 1)) + val read = process(home, SshScripts.readHookRequest(node, ticket)) + val output = read.inputStream.bufferedReader().readText() + assertTrue(read.waitFor(5, TimeUnit.SECONDS)); assertEquals(2, read.exitValue()) + assertEquals("", output) + } finally { home.deleteRecursively() } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostBrowseTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostBrowseTest.kt new file mode 100644 index 0000000000..d97c90af70 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostBrowseTest.kt @@ -0,0 +1,284 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.MirrorServer +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.ssh.HostBrowse +import dev.nodeterm.protocol.ssh.SshScripts +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A27: what the direct-SSH browse makes of a computer a desktop drives over SSH, as pure rules + * (the shell that gathers it runs in SshScriptsTest, the whole path against real tmux in + * SshTransportTest). + */ +class HostBrowseTest { + private val now = 10_000_000L + + /** The host's own listing: the desktop's v2-shaped projects, its node-terminal sessions, its mirror. */ + private fun base( + workspace: String = """{"version":2,"projects":[ + {"id":"p1","name":"Own","cwd":"/home/u/own","nodes":[{"id":"own-1","kind":"terminal"}]}, + {"id":"p2","name":"Its SSH","ssh":{"server":{"host":"box","user":"me"},"remoteCwd":"~"},"nodes":[{"id":"ssh-1","kind":"terminal"}]}]}""", + sessions: String = "nt-own-1\n", + status: String = """{"v":1,"updatedAt":5,"nodes":{"own-1":{"state":"working","updatedAt":5}}, + "settings":{"claudePermissionMode":"manual","claudeAccounts":[]}, + "inbox":{"events":[{"id":"e-own","ts":50,"nodeId":"own-1","kind":"done","title":"own"}],"nodes":{}}, + "server":{"version":"0.2.17"}}""" + ): ProjectsSnapshot = ProjectsParser.parseBlob( + workspace + "\n" + ProjectsParser.PROJECTS_MARK + "\n" + sessions + "\n" + ProjectsParser.STATUS_MARK + "\n" + status, + now = now + ) + + private fun out( + rmt: List = emptyList(), + slices: List> = emptyList(), + files: List> = emptyList(), + ud: String? = "/ud" + ) = HostBrowse.Output(true, ud, "", rmt, slices, files) + + private fun slice(updatedAt: Long, nodes: String = "", events: String = "", settings: String? = null) = + """{"v":1,"updatedAt":$updatedAt,"nodes":{$nodes},"inbox":{"events":[$events],"nodes":{}}${settings?.let { ",\"settings\":$it" } ?: ""}}""" + + @Test + fun `a slice is data up to twice the heartbeat old, and none a millisecond after`() { + val edge = now - HostBrowse.SLICE_STALE_MS + val node = """"d-1":{"state":"blocked","agentId":"claude","updatedAt":1}""" + val fresh = HostBrowse.assemble(base(), out(slices = listOf("proj-d" to slice(edge, node))), now) + assertEquals(AgentState.BLOCKED, fresh.statusOf("d-1")?.state) + assertEquals(listOf("proj-d"), fresh.projects.filter { it.drivenRemotely }.map { it.id }, "its slice names the project") + + val stale = HostBrowse.assemble(base(), out(slices = listOf("proj-d" to slice(edge - 1, node))), now) + assertNull(stale.statusOf("d-1"), "a desktop that stopped pushing says nothing about its sessions") + assertTrue(stale.projects.none { it.drivenRemotely }) + assertEquals(120_000L, HostBrowse.SLICE_STALE_MS) + } + + @Test + fun `the host's own index wins, and the paired desktop's own SSH project is never listed twice (A09)`() { + val files = listOf( + // The paired desktop drives this computer as its SSH project p2: its file and session are here. + // The file may already name a node its index cache does not (a phone appended it): that + // node's session is listed, but never as a second copy of p2. + "/srv/self" to """{"id":"p2","name":"Its SSH again","nodes":[{"id":"ssh-1"},{"id":"ssh-new"}]}""", + // A folder the host's own desktop has as a project reads that file through its index entry. + "/home/u/own/" to """{"id":"other","name":"Same folder","nodes":[{"id":"x-1"}]}""", + "/srv/drv" to """{"id":"proj-drv","name":"Driven","nodes":[{"id":"own-1"},{"id":"drv-1"},{"id":"drv.2"}]}""" + ) + val snap = HostBrowse.assemble( + base(), out(rmt = listOf("nt-ssh-1", "nt-ssh-new", "nt-x-1", "nt-drv-1", "nt-drv_2", "nt-own-1"), files = files), now + ) + assertEquals(listOf("Own", "Its SSH", "Driven", HostBrowse.OTHER_SESSIONS_NAME), snap.projects.map { it.name }) + assertEquals("me@box", snap.projects[1].sshTarget, "still the desktop's, reached through it") + assertEquals(listOf("drv-1", "drv.2"), snap.projects[2].nodes.map { it.id }, "own-1 is the host's own node") + assertTrue(snap.isLive("drv.2"), "a node id tmux spells with `_` is still its project's session") + assertEquals(listOf("ssh-new", "x-1"), snap.projects[3].nodes.map { it.id }, "sessions no listed project names") + // A name on both sockets is the host's own session. + assertEquals(TmuxNames.SOCKET, snap.socketOf("own-1")) + assertEquals(TmuxNames.REMOTE_SOCKET, snap.socketOf("drv-1")) + assertEquals(TmuxNames.REMOTE_SOCKET, snap.socketOf("ssh-1")) + assertNull(snap.socketOf("nobody")) + } + + @Test + fun `a project file is listed when one of its sessions runs here or its desktop is connected`() { + val file = "/srv/quiet" to """{"id":"proj-q","name":"Quiet","nodes":[{"id":"q-1"}]}""" + // Found above some other session, nothing of its own running, no slice: nobody drives it. + assertTrue(HostBrowse.assemble(base(), out(rmt = listOf("nt-z-1"), files = listOf(file)), now).projects.none { it.name == "Quiet" }) + // Its desktop is connected (a fresh slice), though its sessions are not running. + val connected = HostBrowse.assemble(base(), out(files = listOf(file), slices = listOf("proj-q" to slice(now))), now) + assertEquals(listOf("q-1"), connected.projects.single { it.name == "Quiet" }.nodes.map { it.id }) + assertFalse(connected.isLive("q-1")) + // A stale slice is not "connected". + assertTrue( + HostBrowse.assemble(base(), out(files = listOf(file), slices = listOf("proj-q" to slice(0))), now).projects.none { it.name == "Quiet" } + ) + } + + @Test + fun `the shared file's machine-local fields are not read, and its folder is where it was found`() { + val files = listOf( + "/srv/a" to """{"id":"proj-a","name":"A","cwd":"/elsewhere","closed":true, + "ssh":{"server":{"host":"evil"},"remoteCwd":"~"}, + "nodes":[{"id":"a-1","cwd":"./sub"},{"id":"a-2","cwd":"."},{"id":"a-3","cwd":"/abs"}]}""", + // No id (a newer writer dropped it), and a second file reusing the first one's id. + "/srv/b/" to """{"name":"B","nodes":[{"id":"b-1"}]}""", + "/srv/c" to """{"id":"proj-a","name":"C","nodes":[{"id":"c-1"}]}""", + "relative/d" to """{"id":"proj-d","name":"D","nodes":[{"id":"d-1"}]}""" + ) + val snap = HostBrowse.assemble(base(), out(rmt = listOf("nt-a-1", "nt-b-1", "nt-c-1", "nt-d-1"), files = files), now) + val a = snap.projects.single { it.name == "A" } + assertEquals("/srv/a", a.cwd) + assertNull(a.sshTarget, "its sessions run HERE: never refused as another host's") + assertFalse(a.closed) + assertEquals(listOf("/srv/a/sub", "/srv/a", "/abs"), a.nodes.map { it.cwd }) + assertEquals("host:/srv/b", snap.projects.single { it.name == "B" }.id) + assertEquals("host:/srv/c", snap.projects.single { it.name == "C" }.id, "ids stay unique") + assertTrue(snap.projects.none { it.name == "D" }, "a folder that is not absolute is not trusted") + assertEquals(listOf("d-1"), snap.projects.single { it.id == HostBrowse.OTHER_SESSIONS_ID }.nodes.map { it.id }) + } + + @Test + fun `the host's own mirror wins, and fresh slices add their nodes and inbox`() { + val drvSlice = slice( + now, + nodes = """"own-1":{"state":"done","updatedAt":9},"drv-1":{"state":"waiting","agentId":"codex","updatedAt":9}""", + events = """{"id":"e-drv","ts":20,"nodeId":"drv-1","kind":"question","title":"q"},{"id":"e-own","ts":50,"nodeId":"own-1","kind":"done","title":"dup"}""", + settings = """{"claudePermissionMode":"plan","claudeAccounts":[]}""" + ) + val snap = HostBrowse.assemble(base(), out(rmt = listOf("nt-drv-1"), slices = listOf("proj-drv" to drvSlice)), now) + assertEquals(AgentState.WORKING, snap.statusOf("own-1")?.state, "the host's own entry") + assertEquals(AgentState.WAITING, snap.statusOf("drv-1")?.state) + assertEquals(listOf("e-drv", "e-own"), snap.status!!.inbox!!.events.map { it.id }, "oldest first, once each") + assertEquals("own", snap.status!!.inbox!!.events.last().title) + assertEquals("manual", snap.status!!.settings!!.claudePermissionMode, "the host's own settings") + assertEquals(MirrorServer("0.2.17", null, null), snap.status!!.server) + assertEquals(now, snap.status!!.updatedAt) + // A computer that runs no nodeterm of its own: the slice's settings are all there is, and with + // no project file the slice alone names the project and its agents. + val bare = ProjectsParser.parseBlob("", now) + val only = HostBrowse.assemble(bare, out(ud = null, rmt = listOf("nt-drv-1"), slices = listOf("proj-drv" to drvSlice)), now) + assertEquals("plan", only.status!!.settings!!.claudePermissionMode) + val project = only.projects.single() + assertEquals("proj-drv", project.name) + assertEquals(listOf("own-1", "drv-1"), project.nodes.map { it.id }) + assertEquals("codex", project.nodes.last().agentId) + assertTrue(only.isLive("drv-1") && !only.isLive("own-1")) + } + + @Test + fun `an unnamed session takes its agent from a fresh slice, and bad slice names are ignored`() { + val snap = HostBrowse.assemble( + base(), + out( + rmt = listOf("nt-f-1", "nt-n-9"), + // The project file predates n-9; its slice already reports it. + files = listOf("/srv/f" to """{"id":"proj-f","name":"F","nodes":[{"id":"f-1"}]}"""), + slices = listOf( + "proj-f" to slice(now, """"n-9":{"state":"working","agentId":"gemini","updatedAt":1}"""), + "bad id" to slice(now, """"z-1":{"state":"working","updatedAt":1}""") + ) + ), + now + ) + assertNull(snap.statusOf("z-1")) + assertEquals(listOf("f-1"), snap.projects.single { it.name == "F" }.nodes.map { it.id }) + val other = snap.projects.single { it.id == HostBrowse.OTHER_SESSIONS_ID } + assertEquals(listOf("n-9"), other.nodes.map { it.id }) + assertEquals("gemini", other.nodes.single().agentId) + assertEquals(AgentState.WORKING, snap.statusOf("n-9")?.state) + assertTrue(other.drivenRemotely) + assertTrue(snap.isLive("n-9")) + } + + @Test + fun `the split keeps an older script's output whole and drops session names it did not make`() { + val old = "${SshScripts.META_START}\nud=/ud\n${SshScripts.META_END}\n{\"version\":2,\"projects\":[]}\n${ProjectsParser.PROJECTS_MARK}\nnt-a\n${ProjectsParser.STATUS_MARK}\n{}\n" + val o = HostBrowse.split(old) + assertEquals("/ud", o.userData) + assertTrue(o.blob.contains(ProjectsParser.STATUS_MARK) && o.rmtSessions.isEmpty() && o.slices.isEmpty()) + assertFalse(o.nothingFound(now)) + val none = HostBrowse.split("${SshScripts.META_START}\nud=\n${SshScripts.META_END}\n\n${SshScripts.RMT_MARK}\nnt-ok\nnt-x;y\nfoo\n${SshScripts.END_MARK}\n") + assertEquals(listOf("nt-ok"), none.rmtSessions) + assertFalse(none.nothingFound(now), "a session a desktop runs here is something") + assertTrue(HostBrowse.split("${SshScripts.META_START}\nud=\n${SshScripts.META_END}\n${SshScripts.RMT_MARK}\n${SshScripts.END_MARK}\n").nothingFound(now)) + } + + /** + * The review of A27a: the desktop never deletes a slice, so a computer a desktop drove once keeps + * its stale ones for good. Only a slice that is still data is "something found"; stale, broken or + * misnamed ones leave a computer with no data dir and no session reading as "not found". + */ + @Test + fun `slices that are no data do not make a computer with nothing else an empty one`() { + val node = """"d-1":{"state":"blocked","agentId":"claude","updatedAt":1}""" + val junk = listOf( + "proj-old" to slice(now - HostBrowse.SLICE_STALE_MS - 1, node), + "proj-broken" to "{not json", + "bad id" to slice(now, node) + ) + assertTrue(out(slices = junk, ud = null).nothingFound(now), "only slices the listing throws away") + assertTrue(HostBrowse.assemble(base(workspace = "", sessions = "", status = ""), out(slices = junk, ud = null), now).projects.isEmpty()) + assertFalse(out(slices = junk + ("proj-live" to slice(now, node)), ud = null).nothingFound(now), "a fresh slice is data") + assertFalse(out(slices = junk, ud = "/ud").nothingFound(now), "a data dir is something, whatever the slices") + assertFalse(out(rmt = listOf("nt-x"), slices = junk, ud = null).nothingFound(now)) + } + + /** The review of A26: whether the computer advertises its relay right now, from the meta block. */ + @Test + fun `the meta block says whether the computer advertises its relay`() { + fun meta(lines: String) = HostBrowse.split("${SshScripts.META_START}\nud=/ud\n$lines${SshScripts.META_END}\n${SshScripts.END_MARK}\n") + assertEquals(true, meta("relay=1\n").relayAdvertised) + assertEquals(false, meta("relay=0\n").relayAdvertised) + assertNull(meta("").relayAdvertised, "a block that does not say is unknown, not 'off'") + assertNull(meta("relay=yes\n").relayAdvertised) + // Only the meta block counts: a project file or session name cannot claim it. + assertNull(HostBrowse.split("${SshScripts.META_START}\nud=/ud\n${SshScripts.META_END}\nrelay=1\n${SshScripts.END_MARK}\n").relayAdvertised) + } + + @Test + fun `the install metadata line says what the block says, and nothing it does not`() { + assertEquals("nodeterm server 0.2.17 · 1e56f83 · installed 2026-09-01", MirrorServer("0.2.17", "1e56f83", "2026-09-01T10:00:00Z").describe()) + assertEquals("nodeterm server · installed yesterday", MirrorServer(null, null, "yesterday").describe()) + assertNull(MirrorServer(null, null, null).describe()) + val parsed = ProjectsParser.parseStatus("""{"server":{"version":"${"9".repeat(65)}","commit":"a\u0007b","installedAt":"2026-09-01"}}""") + assertEquals(MirrorServer(null, null, "2026-09-01"), parsed!!.server, "overlong or control text is dropped") + assertNull(ProjectsParser.parseStatus("""{"server":{"version":""}}""")!!.server) + } + + /** The Compose side is only type-checked here, so what it shows is pinned in its source. */ + @Test + fun `the app marks a driven project and shows a Server Edition's install line`() { + assertTrue(AppSourcePins.ui("HostScreen.kt").contains("snapshot.status?.server?.describe()")) + val header = AppSourcePins.blockAfter(AppSourcePins.ui("SessionsTab.kt"), "private fun ProjectHeader(") + assertTrue(header.contains("if (project.drivenRemotely)"), header) + } + + /** + * The desktop facts these rules copy, read from the desktop's own sources so a change there turns + * this red instead of silently blanking (or never blanking) a slice. + */ + @Test + fun `the desktop contracts the browse reads are the ones the desktop writes`() { + // Each file by a literal path, so WorkflowPathFilterTest can check that editing it runs the + // Android workflow (android.yml lists the two under src/main/remote-ssh and src/server/config.ts). + val repo = InteropHarness.repoRoot + fun text(f: File) = f.readText().replace("\r\n", "\n") + val push = text(File(repo, "src/main/remote-ssh/remote-status-push.ts")) + assertTrue(Regex("""export const STATUS_HEARTBEAT_MS = 60_000\b""").containsMatchIn(push)) + assertEquals(60_000L, HostBrowse.STATUS_HEARTBEAT_MS) + val sshProject = text(File(repo, "src/main/remote-ssh/ssh-project.ts")) + assertTrue(sshProject.contains("return `\${dir}/agent-status-\${projectId}.json`")) + assertTrue(sshProject.contains("`\${c.remoteHome}/.nodeterm`")) + // The SSH mirror's file carries the desktop's project id: what a slice is named by. + assertTrue(text(File(repo, "src/core/workspace-files.ts")).contains("cache: projectToFile(p, revOf(p.id), savedAt, p.id)")) + val config = text(File(repo, "src/server/config.ts")) + assertTrue(config.contains("const dataDir = resolveDataDir(env, argv)")) + assertTrue(config.contains("return path.join(os.homedir(), '.nodeterm-server')")) + assertTrue(text(File(repo, "src/core/tmux-naming.ts")).contains("export const TMUX_SOCKET = '${TmuxNames.SOCKET}'")) + val ssh = text(File(repo, "src/shared/ssh.ts")) + assertTrue(ssh.contains("const socket = opts.socket ?? '${TmuxNames.REMOTE_SOCKET}'")) + assertTrue(Regex("""'new-session',\s*'-A',\s*'-s',\s*posixQuote\(opts\.sessionId\),\s*'-c',""").containsMatchIn(ssh), "sessions start in the node's folder") + assertTrue(text(File(repo, "src/core/agent-status-mirror.ts")).contains("export interface MirrorServer")) + // `relay=` reads the file the desktop writes while its phone host is registered at the relay, + // and removes when the host stops (remote access turned off): its absence is "off" (A26). + val advertise = text(File(repo, "src/main/remote/relay-advertise.ts")) + assertTrue(advertise.contains("const FILE = path.join(os.homedir(), '.nodeterm', 'relay.json')")) + assertTrue(SshScripts.browse().contains("[ -s \"\$HOME/.nodeterm/relay.json\" ]")) + val host = text(File(repo, "src/main/remote/standing-host.ts")) + val stop = host.substring(host.indexOf("function stop(): void {")).let { it.substring(0, it.indexOf("\n }\n")) } + assertTrue(stop.contains("void removeRelayAdvertisement()"), stop) + assertTrue( + Regex("""const want = enabled && relayAllowed\(\)\s+if \(want && !running\) start\(\)\s+else if \(!want && running\) stop\(\)""") + .containsMatchIn(host), + "turning remote access off stops the host" + ) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostCertificatePinTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostCertificatePinTest.kt new file mode 100644 index 0000000000..9890bb1674 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostCertificatePinTest.kt @@ -0,0 +1,169 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.pairing.SshIdentity +import dev.nodeterm.protocol.ssh.HostKeyChangedException +import dev.nodeterm.protocol.ssh.HostKeyPin +import dev.nodeterm.protocol.ssh.LanRefresh +import dev.nodeterm.protocol.ssh.LanReport +import dev.nodeterm.protocol.ssh.SshHostConnection +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.transport.verification.HostKeyVerifier +import org.apache.sshd.certificate.OpenSshCertificateBuilder +import org.apache.sshd.common.NamedFactory +import org.apache.sshd.common.config.keys.OpenSshCertificate +import org.apache.sshd.common.keyprovider.HostKeyCertificateProvider +import org.apache.sshd.common.keyprovider.KeyPairProvider +import org.apache.sshd.common.signature.Signature +import org.apache.sshd.server.SshServer +import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator +import java.security.KeyPairGenerator +import java.security.PublicKey +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +/** + * Review of A74-refresh: a computer whose sshd offers a host certificate (`HostCertificate`). sshj + * negotiates the certificate whenever the server offers one, and the phone used to pin the + * CERTIFICATE's own fingerprint: a key the desktop never reports (it reads the plain `.pub` files), so + * every relay report seemed to say the key had changed, and a certificate reissued for the same key + * would have been a changed key. The pin is the key the certificate certifies, which is also how + * OpenSSH fingerprints a certificate. Against a real MINA sshd that presents the certificate. + */ +class HostCertificatePinTest { + private val servers = ArrayList() + private val identity = SshIdentity.generate() + private val hostKeys = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() + private val ca = KeyPairGenerator.getInstance("EC").apply { initialize(256) }.generateKeyPair() + + /** What the desktop reports for this computer: the plain key, from its `.pub` file. */ + private val reportedFp = SshHostConnection.fingerprint(hostKeys.public) + + @AfterTest + fun tearDown() { + servers.forEach { it.stop(true) } + } + + private fun certificate(serial: Long): OpenSshCertificate = + OpenSshCertificateBuilder.hostCertificate().publicKey(hostKeys.public).serial(serial).id("box-$serial") + .principals(listOf("127.0.0.1")).validAfter(0L).validBefore(-1L).sign(ca) + + /** An sshd that presents [cert] for its host key and lets the phone in as `dev`. */ + private fun server(cert: OpenSshCertificate): Int { + val server = SshServer.setUpDefaultServer() + server.host = "127.0.0.1" + server.port = 0 + server.keyPairProvider = KeyPairProvider.wrap(hostKeys) + server.hostKeyCertificateProvider = HostKeyCertificateProvider { listOf(cert) } + // MINA 2.14 names the CERTIFICATE algorithm inside the key-exchange signature, where OpenSSH's + // sshd (and what sshj expects) names the plain one: sshj otherwise refuses with "Expected + // 'ssh-ed25519' key algorithm, but got: ssh-ed25519-cert-v01@openssh.com" (measured). Make the + // test server say what sshd says. + server.signatureFactories = server.signatureFactories.map { f -> + object : NamedFactory { + override fun getName(): String = f.name + override fun create(): Signature { + val s = f.create() + return object : Signature by s { + override fun getSshAlgorithmName(algo: String): String = + s.getSshAlgorithmName(algo).replace("-cert-v01@openssh.com", "") + } + } + } + } + val accepted = identity.keyPair.public.encoded + server.publickeyAuthenticator = PublickeyAuthenticator { user, key, _ -> user == "dev" && key.encoded.contentEquals(accepted) } + server.start() + servers += server + return server.port + } + + private class MemoryPin(var value: String? = null, private val paired: List = emptyList()) : HostKeyPin { + var pinCalls = 0 + override fun pinned(): String? = value + override fun pin(fingerprint: String) { + pinCalls++ + value = fingerprint + } + override fun anchors(): List = paired + } + + private fun connect(port: Int, pin: HostKeyPin) = SshHostConnection.connect("127.0.0.1", port, "dev", identity, pin).close() + + /** What sshj hands a host-key verifier from this server, fingerprinted as the phone did before this review. */ + private fun certificateOwnFingerprint(port: Int): String { + var seen: String? = null + val client = SSHClient() + client.addHostKeyVerifier(object : HostKeyVerifier { + override fun verify(hostname: String, port: Int, key: PublicKey): Boolean { + seen = SshHostConnection.fingerprint(key) + return false + } + override fun findExistingAlgorithms(hostname: String, port: Int): List = emptyList() + }) + runCatching { client.connect("127.0.0.1", port) } + runCatching { client.disconnect() } + return seen ?: error("the server presented no host key") + } + + @Test + fun `a host certificate is pinned as the key it certifies, which is the key the computer reports`() { + val port = server(certificate(1)) + val ownFp = certificateOwnFingerprint(port) + assertNotEquals(reportedFp, ownFp, "the server presents a certificate, not the plain key") + + // Anchored to what the pairing named: the plain key. + val anchored = MemoryPin(paired = listOf(reportedFp)) + connect(port, anchored) + assertEquals(reportedFp, anchored.value) + // Trust on first use (an older desktop, or a computer added by its SSH address, whose screen + // compares the pin with `ssh-keygen -lf` of the plain `.pub`): the same key. + val tofu = MemoryPin() + connect(port, tofu) + assertEquals(reportedFp, tofu.value) + + // A relay report names the plain key, which is the pin: nothing to drop, nothing to say. + val record = PairedHost( + id = "dev-1", name = "Box", host = "192.168.1.5", port = 22, user = "dev", sshAvailable = true, + hostKeyB64 = null, relay = null, sshHostKeyFingerprint = anchored.value, pairedAt = 1, + sshHostKeyAnchors = listOf(reportedFp) + ) + assertNull(LanRefresh.apply(record, LanReport(null, listOf(reportedFp)), refusedHostKey = null)) + } + + @Test + fun `a certificate reissued for the same key is not a changed key`() { + val pin = MemoryPin(paired = listOf(reportedFp)) + connect(server(certificate(1)), pin) + assertEquals(reportedFp, pin.value) + // The computer's sshd now presents a renewed certificate (another serial, another signature). + val renewed = server(certificate(2)) + connect(renewed, pin) + assertEquals(reportedFp, pin.value) + assertEquals(1, pin.pinCalls, "a connect that matched the pin pins nothing again") + } + + @Test + fun `a pin an older build took from the certificate itself still connects, and is re-spelled as the key`() { + val port = server(certificate(1)) + val legacy = MemoryPin(value = certificateOwnFingerprint(port), paired = listOf(reportedFp)) + connect(port, legacy) + assertEquals(reportedFp, legacy.value, "the same key, now named as the computer names it") + // Another server's certificate never matches a pin by its own fingerprint. + val stranger = MemoryPin(value = "SHA256:" + "A".repeat(43)) + val refused = assertFailsWith { connect(port, stranger) } + assertEquals(reportedFp, refused.actual, "a refused certificate is named by its key, as the computer would report it") + assertEquals("SHA256:" + "A".repeat(43), stranger.value) + } + + @Test + fun `a certificate is named by its key, and a plain key by itself`() { + val cert = com.hierynomus.sshj.userauth.certificate.Certificate.getBuilder().publicKey(hostKeys.public).build() + assertEquals(reportedFp, SshHostConnection.hostKeyFingerprint(cert)) + assertEquals(reportedFp, SshHostConnection.hostKeyFingerprint(hostKeys.public)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostKeyAnchorsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostKeyAnchorsTest.kt new file mode 100644 index 0000000000..fadb3e2bd8 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostKeyAnchorsTest.kt @@ -0,0 +1,182 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.pairing.PairingClient +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.pairing.PairingResult +import dev.nodeterm.protocol.ssh.HostKeyAnchors +import dev.nodeterm.protocol.ssh.HostKeyChangedException +import dev.nodeterm.protocol.ssh.HostKeyNotPairedException +import dev.nodeterm.protocol.ssh.SshFallback +import dev.nodeterm.protocol.ssh.SshHostConnection +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import net.schmizz.sshj.common.Buffer +import java.io.ByteArrayOutputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Audit A49-anchor: the computer's SSH host key fingerprints arrive in the sealed pairing answer and + * anchor the first SSH connect. The desktop's side, the sealed exchange end to end and the connect + * against a real SSH server are in PairingInteropTest and SshTransportTest; these are the pieces that + * need neither node nor a server. + */ +class HostKeyAnchorsTest { + // GitHub publishes its SSH host keys with the fingerprints OpenSSH prints for them (docs.github.com, + // "GitHub's SSH key fingerprints"). The desktop's reader is checked against the same pair + // (src/main/ssh-host-keys.test.ts), so both ends print what OpenSSH prints. + private val githubEd25519 = "AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" + private val githubEd25519Fp = "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU" + private val githubEcdsa = + "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=" + private val githubEcdsaFp = "SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM" + + private fun key(b64: String): java.security.PublicKey { + // sshj reads ECDSA keys through BouncyCastle, which it registers on first use of its + // SecurityUtils (an SSHClient does it; the app registers the full provider at startup). + check(net.schmizz.sshj.common.SecurityUtils.isBouncyCastleRegistered()) { "BouncyCastle is on the test classpath" } + return Buffer.PlainBuffer(Base64.getDecoder().decode(b64)).readPublicKey() + } + + @Test + fun `sshj fingerprints a host key the way OpenSSH prints it, the form the desktop sends`() { + assertEquals(githubEd25519Fp, SshHostConnection.fingerprint(key(githubEd25519))) + assertEquals(githubEcdsaFp, SshHostConnection.fingerprint(key(githubEcdsa))) + assertTrue(HostKeyAnchors.isFingerprint(githubEd25519Fp) && HostKeyAnchors.isFingerprint(githubEcdsaFp)) + } + + @Test + fun `only well-formed fingerprints are kept, once each, at most the cap`() { + fun parse(json: String) = HostKeyAnchors.parse(Json.parseToJsonElement(json)) + assertEquals( + listOf(githubEd25519Fp, githubEcdsaFp), + parse("""["$githubEd25519Fp", 7, null, "MD5:aa:bb", "SHA256:short", "sha256:${githubEcdsaFp.drop(7)}", "$githubEd25519Fp", "$githubEcdsaFp"]""") + ) + // Not an array (or absent): none, which is what an older desktop gives. + assertEquals(emptyList(), parse(""""$githubEd25519Fp"""")) + assertEquals(emptyList(), parse("""{"a":"$githubEd25519Fp"}""")) + assertEquals(emptyList(), HostKeyAnchors.parse(null)) + val many = (0 until HostKeyAnchors.MAX + 5).map { "SHA256:" + it.toString().padStart(43, 'A') } + assertEquals(many.take(HostKeyAnchors.MAX), HostKeyAnchors.parse(JsonArray(many.map(::JsonPrimitive)))) + } + + private val payload = PairingPayload.parse( + """{"v":1,"host":"10.0.0.2","user":"u","token":"t","pairPort":1,"nodeterm":true,"name":"Box"}""" + )!! + + @Test + fun `the paired record keeps them, and a record without them is what it was`() { + val anchored = PairedHost.from(payload, PairingResult("dev-1", "tok", null, null, sshHostKeyFingerprints = listOf(githubEd25519Fp)), now = 5) + assertEquals(listOf(githubEd25519Fp), anchored.sshHostKeyAnchors) + assertEquals(null, anchored.sshHostKeyFingerprint, "nothing is pinned at pairing") + assertEquals(anchored, PairedHost.fromJson(anchored.toJson())) + assertEquals(JsonArray(listOf(JsonPrimitive(githubEd25519Fp))), anchored.toJson()["sshHostKeyAnchors"]) + + val plain = PairedHost.from(payload, PairingResult("dev-1", "tok", null, null), now = 5) + assertFalse("sshHostKeyAnchors" in plain.toJson(), "a pairing without keys writes the record it always wrote") + assertEquals(plain, PairedHost.fromJson(plain.toJson())) + + // A hand-edited or damaged record keeps only what is a fingerprint. + val edited = Json.parseToJsonElement( + anchored.toJson().toString().replace("\"$githubEd25519Fp\"", "\"$githubEd25519Fp\",\"not-a-fingerprint\"") + ).jsonObject + assertEquals(listOf(githubEd25519Fp), PairedHost.fromJson(edited)!!.sshHostKeyAnchors) + } + + @Test + fun `a key the pairing did not name is a host key change to the fallback, with its own words`() { + val e = HostKeyNotPairedException(listOf(githubEd25519Fp, githubEcdsaFp), "SHA256:whoever-answered") + assertIs(e) + assertEquals("SHA256:whoever-answered", e.actual) + val fact = e.message!! + assertTrue(fact.contains("SHA256:whoever-answered") && fact.contains("none of the 2 keys the computer reported"), fact) + assertTrue(fact.contains("the computer reported to this phone (when it was paired, or since through the relay)"), fact) + // The likely causes before the alarming one, as for a changed pin. + assertTrue(fact.indexOf("network address") in 0 until fact.indexOf("intercepting"), fact) + assertTrue(HostKeyNotPairedException(listOf(githubEd25519Fp), "x").message!!.contains("not the key the computer reported")) + + // Auto: refused over SSH, the relay still tried, and a warning kept up. + val auto = SshFallback.afterFailure(e, relayAllowed = true, relayConfigured = true) + assertIs(auto) + assertTrue(auto.warning!!.contains(fact), auto.warning) + // "Only on my network": the hard stop. + assertIs(SshFallback.afterFailure(e, relayAllowed = false, relayConfigured = true)) + } + + /** + * The app's side of the anchors (review of A49-anchor), which only a device runs and so is pinned in + * the source. [dev.nodeterm.protocol.ssh.HostKeyPin.anchors] has a default (none: trust on first use), + * so an app pin that drops its override, or reads another field, still compiles, type-checks and + * passes every protocol test, while every phone silently goes back to trust on first use. + */ + @Test + fun `the app's pin hands the verifier the keys its record keeps, and every SSH dial uses that pin`() { + val connections = AppSourcePins.app("conn/ConnectionManager.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(connections, "private fun pinFor(host: PairedHost, lease: HostLifetime.Lease)"), + "graph.hosts.currentHost(host.id, host.hostKeyB64) { lifetime.isCurrent(lease) }", + "override fun pinned(): String? = current().sshHostKeyFingerprint", + "graph.hosts.updateCurrent(host.id, host.hostKeyB64, { lifetime.isCurrent(lease) }) { it.copy(sshHostKeyFingerprint = fingerprint) }", + "override fun anchors(): List = current().sshHostKeyAnchors" + ) + // The paired computer's SSH dial goes through that pin, read fresh from the record each time. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(connections, "private suspend fun connectLocked(trigger: Trigger, lease: HostLifetime.Lease)"), + "SshHostConnection.connect(", + "host.host, host.port, host.user, graph.sshIdentity, pinFor(host, lease)," + ) + assertEquals(1, Regex("""SshHostConnection\.connect\(""").findAll(connections).count(), "one SSH dial, the pinned one") + // And the record carries what the sealed answer named: pairing stores PairedHost.from's record + // as it is (a `.copy(...)` on that line could drop the anchors again). + val pair = AppSourcePins.ui("PairScreen.kt") + assertTrue(pair.lines().any { it.trim() == "val host = PairedHost.from(p, result)" }, "PairScreen keeps PairedHost.from's record") + AppSourcePins.assertInOrder(pair, "val host = PairedHost.from(p, result)", "graph.hosts.publishPairing(host, previous") + val store = AppSourcePins.app("data/HostStore.kt") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(store, "fun publishPairing("), "saveToken()", "upsert(host)") + } + + @Test + fun `a plaintext pairing answer's host keys are ignored`() = runBlocking { + // A clear answer could have been rewritten on the LAN; the desktop never sends the field there, + // and the phone must not take it from one either. + val answer = """{"ok":true,"deviceId":"dev-1","agentToken":"tok","sshHostKeyFingerprints":["$githubEd25519Fp"]}""" + val server = ServerSocket(0, 1, InetAddress.getLoopbackAddress()) + val thread = Thread { + server.accept().use { sock -> + val input = sock.getInputStream() + val head = ByteArrayOutputStream() + var last4 = 0 + while (last4 != 0x0d0a0d0a) { + val c = input.read() + if (c < 0) break + head.write(c) + last4 = (last4 shl 8) or c + } + val length = head.toString(Charsets.ISO_8859_1.name()).lines() + .firstNotNullOfOrNull { if (it.startsWith("Content-Length:", true)) it.substringAfter(':').trim().toInt() else null } ?: 0 + input.readNBytes(length) + val body = answer.toByteArray() + sock.getOutputStream().write("HTTP/1.1 200 OK\r\nContent-Length: ${body.size}\r\n\r\n".toByteArray() + body) + sock.getOutputStream().flush() + } + }.apply { isDaemon = true; start() } + try { + val result = PairingClient(readTimeoutMs = 5_000).pair(payload.copy(host = "127.0.0.1", pairPort = server.localPort, hostKey = null), "ssh-ed25519 AAAA test", "Pixel", "android-test") + assertEquals("dev-1", result.deviceId) + assertEquals(emptyList(), result.sshHostKeyFingerprints) + } finally { + server.close() + thread.join(5_000) + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostLifetimeTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostLifetimeTest.kt new file mode 100644 index 0000000000..30c6c2a510 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostLifetimeTest.kt @@ -0,0 +1,227 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.HostLifetime +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.test.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.Job +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test + +class HostLifetimeTest { + @Test fun `permanent retirement refuses every old and fresh lease`() { + val lifetime = HostLifetime(); val lease = lifetime.capture(); var writes = 0 + lifetime.retire { } + assertFalse(lifetime.active); assertFalse(lifetime.isCurrent(lease)) + assertFailsWith { lifetime.capture() } + assertFailsWith { lifetime.publish(lease) { writes++ } } + assertFalse(lifetime.whenCurrent(lease) { writes++ }); assertEquals(0, writes) + } + + @Test fun `a lease from another host lifetime never authorizes this host`() { + val first = HostLifetime(); val second = HostLifetime(); val foreign = first.capture() + assertFalse(second.isCurrent(foreign)) + assertFailsWith { second.publish(foreign) { error("must not publish") } } + assertTrue(second.isCurrent(second.capture())) + } + + @Test fun `retirement cancels an already awaiting approval dial and closes local transport`() = runBlocking { + val lifetime = HostLifetime(); val lease = lifetime.capture() + val awaiting = CompletableDeferred(); val closed = CompletableDeferred() + val dial = async { + assertFailsWith { + lifetime.dial(lease) { + try { awaiting.complete(Unit); awaitCancellation() } + finally { closed.complete(Unit) } + } + } + } + awaiting.await(); lifetime.retire { } + try { assertTrue(withTimeoutOrNull(1000) { dial.await(); closed.await(); true } == true, "Retirement must cancel local approval promptly") } + finally { dial.cancelAndJoin() } + assertTrue(closed.isCompleted) + assertFalse(lifetime.whenCurrent(lease) { error("old approval must not publish") }) + } + + @OptIn(InternalCoroutinesApi::class) + @Test fun `dial cancellation callbacks run outside the lifetime monitor`() = runBlocking { + val lifetime = HostLifetime(); val lease = lifetime.capture() + val entered = CompletableDeferred(); val callbackLocked = CompletableDeferred() + val dial = async { + assertFailsWith { + lifetime.dial(lease) { + currentCoroutineContext()[Job]!!.invokeOnCompletion(onCancelling = true, invokeImmediately = true) { callbackLocked.complete(Thread.holdsLock(lifetime)) } + entered.complete(Unit); awaitCancellation() + } + } + } + entered.await(); lifetime.disconnect { } + try { + assertTrue(withTimeoutOrNull(1000) { dial.await(); true } == true) + assertFalse(callbackLocked.await(), "A cancel callback may acquire a native/store monitor") + } finally { dial.cancelAndJoin() } + } + + @Test fun `produced relay resource is retained for cleanup when cancellation discards its handoff`() = runBlocking { + val lifetime = HostLifetime(); val lease = lifetime.capture() + var produced = false; var closed = false; var callerReceived = false + try { + lifetime.dial(lease) { + produced = true + lifetime.retire { } + "produced-local-transport" + } + callerReceived = true + } catch (_: CancellationException) { + if (produced) closed = true + } + assertTrue(produced); assertTrue(closed); assertFalse(callerReceived) + } + + @Test fun `disconnect invalidates pending work but admits a fresh dial and keeps creation tracking`() { + val lifetime = HostLifetime(); val old = lifetime.capture(); var closes = 0 + lifetime.disconnect { closes++ } + assertTrue(lifetime.active, "A reversible disconnect must keep a saved uncertain creation") + assertFalse(lifetime.isCurrent(old)); assertFailsWith { lifetime.publish(old) { } } + val fresh = lifetime.capture(); var published = false + lifetime.publish(fresh) { published = true } + assertTrue(published); assertEquals(1, closes) + } + + @Test fun `suspended old dial closes after forget and same id replacement instead of adopting or minting`() = runBlocking { + val lifetime = HostLifetime(); val lease = lifetime.capture() + val entered = CompletableDeferred(); val release = CompletableDeferred() + var pairingToken = "original-token"; var closed = false; var adopted = false + val dial = async { + entered.complete(Unit); release.await() + try { lifetime.publish(lease) { adopted = true; pairingToken = "late-old-mint" } } + catch (_: CancellationException) { closed = true } + } + entered.await(); lifetime.retire { }; pairingToken = "same-id-new-pair-token" + release.complete(Unit); dial.await() + assertTrue(closed); assertFalse(adopted); assertEquals("same-id-new-pair-token", pairingToken) + } + + @Test fun `suspended dial cannot reopen after a reversible disconnect`() = runBlocking { + val lifetime = HostLifetime(); val lease = lifetime.capture() + val entered = CompletableDeferred(); val release = CompletableDeferred() + var closed = false; var adopted = false + val dial = async { + entered.complete(Unit); release.await() + try { lifetime.publish(lease) { adopted = true } } catch (_: CancellationException) { closed = true } + } + entered.await(); lifetime.disconnect { }; release.complete(Unit); dial.await() + assertTrue(closed); assertFalse(adopted); assertTrue(lifetime.active) + } + + @Test fun `a suspended relay join cannot start a new approval handshake after retirement`() = runBlocking { + val lifetime = HostLifetime(); val lease = lifetime.capture() + val joined = CompletableDeferred(); val release = CompletableDeferred() + var connectorCalls = 0 + val dial = async { + lifetime.requireCurrent(lease); joined.complete(Unit); release.await() + try { lifetime.requireCurrent(lease); connectorCalls++ } catch (_: CancellationException) { } + } + joined.await(); lifetime.retire { }; release.complete(Unit); dial.await() + assertEquals(0, connectorCalls) + } + + @Test fun `retirement waits for admitted local publication and invalidates every later callback`() { + val lifetime = HostLifetime(); val lease = lifetime.capture() + val entered = CountDownLatch(1); val release = CountDownLatch(1) + val error = AtomicReference(); val writes = mutableListOf() + val publisher = thread { + try { lifetime.publish(lease) { entered.countDown(); assertTrue(release.await(2, TimeUnit.SECONDS)); writes += "old-publication" } } + catch (e: Throwable) { error.set(e) } + } + assertTrue(entered.await(2, TimeUnit.SECONDS)) + val retiring = thread { lifetime.retire { writes += "retired" } } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2) + while (retiring.isAlive && retiring.state != Thread.State.BLOCKED && System.nanoTime() < deadline) Thread.yield() + try { assertEquals(Thread.State.BLOCKED, retiring.state) } + finally { release.countDown(); publisher.join(2000); retiring.join(2000) } + assertFalse(publisher.isAlive); assertFalse(retiring.isAlive); assertNull(error.get()) + assertEquals(listOf("old-publication", "retired"), writes) + assertFalse(lifetime.whenCurrent(lease) { writes += "late-status-or-pin" }) + assertEquals(2, writes.size) + } + + @Test fun `close failures never revive the forgotten object or its old generation`() { + val lifetime = HostLifetime(); val lease = lifetime.capture() + assertFailsWith { lifetime.retire { error("synthetic close failure") } } + assertFalse(lifetime.active); assertFalse(lifetime.isCurrent(lease)) + assertFailsWith { lifetime.capture() } + } + + @Test fun `native dials and callbacks share exact generation and permanent retirement fences`() { + val source = AppSourcePins.app("conn/ConnectionManager.kt") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "suspend fun ensureConnected("), + "val lease = lifetime.capture()", "return mutex.withLock", "lifetime.requireCurrent(lease)", "connectLocked(trigger, lease)") + val dial = AppSourcePins.blockAfter(source, "private suspend fun connectLocked(") + AppSourcePins.assertInOrder(dial, "SshHostConnection.connect(", "pinFor(host, lease)", "adopt(ssh, lease)") + assertTrue(dial.contains("lifetime.dial(lease) { withContext(Dispatchers.IO)")) + assertTrue(dial.contains("dialed?.let { c -> scope.launch(Dispatchers.IO) { runCatching { c.close() } } }")) + assertTrue(dial.contains("scope.launch(Dispatchers.IO) { runCatching { connected.connection.close() } }")) + assertTrue(dial.contains("lifetime.publish(lease) { graph.relayGate.onFailed(hostId, e) }")) + val adopt = AppSourcePins.blockAfter(source, "private fun adopt(") + AppSourcePins.assertInOrder(adopt, "lifetime.publish(lease)", "connectionLease = lease", "conn = c") + assertTrue(adopt.contains("lifetime.whenCurrent(lease) { if (conn === c)")) + assertTrue(adopt.contains("c.setOnChanged { if (lifetime.isCurrent(lease) && conn === c && isWatched)")) + AppSourcePins.assertInOrder(source.substringAfter("class ConnectionManager("), + "private val sessions = HostSessionRegistry(", "session?.retire()", "graph.relayGate.forget(hostId)") + assertTrue(source.contains("fun forget(hostId: String) = sessions.forget(hostId)")) + assertTrue(source.contains("fun retire() = lifetime.retire { disconnectOwned() }")) + assertTrue(source.contains("fun disconnect() = lifetime.disconnect { disconnectOwned() }")) + assertTrue(source.contains("return lifetime.dial(lease)")) + val join = AppSourcePins.blockAfter(source, "private suspend fun dialRelay(") + assertEquals(2, Regex("lifetime\\.requireCurrent\\(lease\\)").findAll(join).count()) + AppSourcePins.assertInOrder(join, "lifetime.requireCurrent(lease)", "RelayApi(graph.hosts.apiBase).join", "lifetime.requireCurrent(lease)", "RelayConnector.connect(") + AppSourcePins.assertInOrder(join, "var dialed: RelayConnector.Connected? = null", "return lifetime.dial(lease)", ").also { dialed = it }", "dialed?.let { connected -> scope.launch(Dispatchers.IO) { runCatching { connected.connection.close() } } }") + val sideAt = source.indexOf("suspend fun viaRelay(") + val sideBody = source.indexOf("): HostConnection {", sideAt) + assertTrue(sideAt >= 0 && sideBody > sideAt) + val side = AppSourcePins.blockAfter(source.substring(sideBody), "): HostConnection") + assertTrue(side.contains("val lease = lifetime.capture()")); assertTrue(side.contains("lifetime.requireCurrent(lease)")) + assertTrue(side.contains("lifetime.publish(lease) { graph.relayGate.onFailed(hostId, e) }")) + assertTrue(side.contains("lifetime.whenCurrent(lease)")); assertTrue(side.contains("runCatching { c.close() }")) + assertTrue(side.contains("lifetime.publish(lease) {\n graph.relayGate.onConnected(hostId)")) + } + + @Test fun `native store guards pin LAN adoption and saved creation with the originating lifetime`() { + val source = AppSourcePins.app("conn/ConnectionManager.kt") + assertTrue(source.contains("graph.hosts.managedCreationStorage(hostId) { lifetime.active }")) + val pin = AppSourcePins.blockAfter(source, "private fun pinFor(") + assertTrue(pin.contains("graph.hosts.currentHost(host.id, host.hostKeyB64) { lifetime.isCurrent(lease) }")) + assertTrue(pin.contains("graph.hosts.updateCurrent(host.id, host.hostKeyB64, { lifetime.isCurrent(lease) })")) + val lan = AppSourcePins.blockAfter(source, "private fun refreshLanLeg(") + assertTrue(lan.contains("lifetime.whenCurrent(lease)")); assertTrue(lan.contains("if (conn !== c) return@whenCurrent")) + assertTrue(lan.contains("graph.hosts.updateCurrent(hostId, before.hostKeyB64, { lifetime.isCurrent(lease) && conn === c })")) + val mint = AppSourcePins.blockAfter(source, "private suspend fun adoptRelayIfAdvertised(") + assertTrue(mint.contains("currentConnection = { lifetime.isCurrent(lease) && conn === ssh }")) + val refresh = AppSourcePins.blockAfter(source, "suspend fun refreshNow(") + assertTrue(refresh.contains("lifetime.whenCurrent(operation) { graph.announce(hostId, listed, onScreen.now(), quiet = reachableQuietly()) }")) + val notifier = AppSourcePins.app("notify/InboxNotifier.kt") + assertFalse(AppSourcePins.blockAfter(notifier, "private fun build(").contains("connections.session(")) + assertTrue(notifier.contains("build(context, host, snapshot, ev, showDetails, quiet)")) + val graph = AppSourcePins.app("NodetermApp.kt") + assertTrue(graph.contains("InboxNotifier.announce(appContext, host, snapshot, onScreen, quiet)")) + val store = AppSourcePins.app("data/HostStore.kt") + val current = AppSourcePins.blockAfter(store, "fun currentHost(") + assertTrue(current.contains("current() && it.hostKeyB64 == expectedHostKey")) + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(store, "fun updateCurrent("), "currentHost(id, expectedHostKey, current) ?: return false", "save(") + val storage = AppSourcePins.blockAfter(store, "fun managedCreationStorage(") + assertTrue(storage.contains("if (!current() || get(hostId) == null) null")) + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(storage, "override fun write("), + "synchronized(this@HostStore)", "if (!current() || get(hostId) == null) throw", ".commit()") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostSessionRegistryTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostSessionRegistryTest.kt new file mode 100644 index 0000000000..e2eb34850e --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/HostSessionRegistryTest.kt @@ -0,0 +1,125 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.HostLifetime +import dev.nodeterm.protocol.host.HostSessionRegistry +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.* +import org.junit.jupiter.api.Test + +class HostSessionRegistryTest { + private class Session(val record: String, val lifetime: HostLifetime = HostLifetime()) + + private fun blocked(thread: Thread) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2) + while (thread.isAlive && thread.state != Thread.State.BLOCKED && System.nanoTime() < deadline) Thread.yield() + assertEquals(Thread.State.BLOCKED, thread.state, "Factory admission must wait for local publication") + } + + private fun admissionDuringPublication(forget: Boolean) { + val record = AtomicReference("old") + val registry = HostSessionRegistry( + create = { _: String -> Session(checkNotNull(record.get()) { "Computer was forgotten" }) }, + retire = { _: String, session: Session? -> session?.lifetime?.retire {} }, + ) + val old = registry.session("same") + val entered = CountDownLatch(1); val release = CountDownLatch(1); val attempting = CountDownLatch(1) + val result = AtomicReference(); val failure = AtomicReference() + val publicationFailure = AtomicReference() + val publisher = Thread { + try { + registry.retireAndPublish(listOf("same")) { + assertFalse(old.lifetime.active) + entered.countDown() + assertTrue(release.await(2, TimeUnit.SECONDS)) + record.set(if (forget) null else "new") + } + } catch (e: Throwable) { publicationFailure.set(e) } + } + val caller = Thread { + attempting.countDown() + try { result.set(registry.session("same")) } catch (e: Throwable) { failure.set(e) } + } + publisher.start() + try { + assertTrue(entered.await(2, TimeUnit.SECONDS)); caller.start() + assertTrue(attempting.await(2, TimeUnit.SECONDS)); blocked(caller) + assertNull(result.get()); assertNull(failure.get()) + } finally { + release.countDown(); publisher.join(3_000); caller.join(3_000) + } + assertFalse(publisher.isAlive); assertFalse(caller.isAlive); assertNull(publicationFailure.get()) + if (forget) { + assertNull(result.get()); assertIs(failure.get()); assertTrue(registry.all().isEmpty()) + } else { + assertNull(failure.get()); val replacement = assertNotNull(result.get()) + assertNotSame(old, replacement); assertEquals("new", replacement.record) + assertTrue(replacement.lifetime.active); assertSame(replacement, registry.session("same")) + } + } + + @Test fun `same id replacement blocks factory until new saved facts are published`() = admissionDuringPublication(false) + + @Test fun `forget blocks factory until membership is removed`() = admissionDuringPublication(true) + + @Test fun `failed secret publication retires old session and releases admission for retained record`() { + val registry = HostSessionRegistry( + create = { _: String -> Session("retained") }, + retire = { _: String, session: Session? -> session?.lifetime?.retire {} }, + ) + val old = registry.session("same") + assertFailsWith { registry.retireAndPublish(listOf("same")) { error("secret write refused") } } + assertFalse(old.lifetime.active) + val replacement = registry.session("same") + assertNotSame(old, replacement); assertEquals("retained", replacement.record); assertTrue(replacement.lifetime.active) + } + + @Test fun `duplicate retired ids close once and leave unrelated session unchanged`() { + val retired = mutableListOf() + val registry = HostSessionRegistry( + create = { id: String -> Session(id) }, + retire = { id: String, session: Session? -> retired += id; session?.lifetime?.retire {} }, + ) + val old = registry.session("old"); val incoming = registry.session("incoming"); val other = registry.session("other") + var published = false + registry.retireAndPublish(listOf("old", "incoming", "old")) { + assertFalse(old.lifetime.active); assertFalse(incoming.lifetime.active) + assertEquals(listOf("other"), registry.all().map { it.record }); published = true + } + assertTrue(published); assertEquals(listOf("old", "incoming"), retired) + assertSame(other, registry.session("other")); assertTrue(other.lifetime.active) + } + + @Test fun `retirement failure prevents publication and releases factory monitor`() { + val registry = HostSessionRegistry( + create = { id: String -> Session(id) }, + retire = { _: String, session: Session? -> session?.lifetime?.retire {}; error("retirement failure") }, + ) + val old = registry.session("old"); var published = false + assertFailsWith { registry.retireAndPublish(listOf("old")) { published = true } } + assertFalse(published); assertFalse(old.lifetime.active) + assertNotSame(old, registry.session("old")) + } + + @Test fun `native pairing and Forget use same factory barrier including incoming id without prior key`() { + val manager = AppSourcePins.app("conn/ConnectionManager.kt").substringAfter("class ConnectionManager(") + assertTrue(manager.contains("private val sessions = HostSessionRegistry(")) + assertTrue(manager.contains("create = { hostId: String -> HostSession(hostId, graph) }")) + assertTrue(manager.contains("fun session(hostId: String): HostSession = sessions.session(hostId)")) + assertTrue(manager.contains("@Synchronized\n fun retireAndPublish(")) + assertTrue(manager.contains("sessions.retireAndPublish(hostIds, publish)")) + assertTrue(manager.contains("fun all(): List = sessions.all()")) + assertFalse(manager.contains("getOrPut(")); assertFalse(manager.contains("HashMap<")) + val pair = AppSourcePins.ui("PairScreen.kt") + AppSourcePins.assertInOrder(pair, + "val retiredIds = PairedHostReplacement.retired(graph.hosts.hosts.value, host, previous).map { it.id } + host.id", + "graph.connections.retireAndPublish(retiredIds)", "graph.hosts.publishPairing(host, previous") + val publication = AppSourcePins.blockAfter(pair, "graph.connections.retireAndPublish(retiredIds)") + assertTrue(publication.contains("graph.hosts.publishPairing(host, previous")) + assertFalse(publication.contains("graph.connections.")) + val hosts = AppSourcePins.ui("HostsScreen.kt") + assertTrue(AppSourcePins.blockAfter(hosts, "graph.connections.retireAndPublish(listOf(host.id))") + .contains("graph.hosts.remove(host.id) { graph.secure.remove(SecureStore.relayTokenKey(host.id)) }")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InboxNotificationTextTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InboxNotificationTextTest.kt new file mode 100644 index 0000000000..5e9b037186 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InboxNotificationTextTest.kt @@ -0,0 +1,188 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.InboxNotificationText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A52: approval and finish notifications put the command text and the agent's last message in the + * notification, and so on the lock screen: Android shows a notification's full content there unless + * the user hides sensitive content, which is not the default. Now the event's own text is left out + * unless the user opts in ("Show details in notifications", off by default), and a public version + * that names only the session and the computer is always set. + * + * What the notification says is pure and tested here. The notification itself cannot be built on a + * JVM, so the wiring (every word comes from [InboxNotificationText], the public version is set, the + * setting defaults to off) is pinned in the app's source ([AppSourcePins]). How a lock screen shows + * it is a device check. + */ +class InboxNotificationTextTest { + private fun event( + kind: InboxKind, + title: String, + detail: String? = null, + interrupted: Boolean = false + ) = InboxEvent( + id = "e1", ts = 1, nodeId = "term-1", agentId = "claude", sessionId = "s", kind = kind, + title = title, detail = detail, interrupted = interrupted, resolved = false, + options = emptyList(), multiSelect = false, pendingId = null + ) + + // Shapes the desktop sends (src/core/agent-status-mirror.ts): a Bash approval's summary, a Write, + // an approval with no summary (its title is the agent's last line), a question, a finished turn. + private val bash = event(InboxKind.APPROVAL, "Run command", "curl -H 'Authorization: Bearer s3cret' https://api.example.com …") + private val write = event(InboxKind.APPROVAL, "Write id_rsa", "/home/me/.ssh/id_rsa (27 lines)") + private val bare = event(InboxKind.APPROVAL, "I'll now delete the customer table in prod.") + private val question = event(InboxKind.QUESTION, "Which of the leaked keys should I rotate first?") + private val done = event(InboxKind.DONE, "Finished", "The password for the staging database is hunter2.") + private val stopped = event(InboxKind.DONE, "Stopped", "Half-way through the migration.", interrupted = true) + + private fun InboxNotificationText.all() = listOfNotNull(title, text, bigText, publicTitle, publicText) + + private fun assertCarriesNothingOf(ev: InboxEvent, words: InboxNotificationText) { + for (secret in listOfNotNull(ev.title, ev.detail)) { + // A DONE title is the desktop's own "Finished"/"Stopped"; the kind label may say the same + // word, which is not the event's text leaking. Everything else must not appear at all. + if (ev.kind == InboxKind.DONE && secret == ev.title) continue + for (field in words.all()) assertFalse(field.contains(secret), "`$secret` is in `$field`") + } + } + + @Test + fun `by default an approval says only that the session needs approval`() { + for (ev in listOf(bash, write, bare)) { + val words = InboxNotificationText.of(ev, "build-bot", "Studio Mac", showDetails = false) + assertEquals("Needs you — build-bot", words.title) + assertEquals("Needs approval", words.text) + assertNull(words.bigText, "no expanded text: it would carry the command") + assertCarriesNothingOf(ev, words) + } + } + + @Test + fun `by default a question does not show the question`() { + val words = InboxNotificationText.of(question, "build-bot", "Studio Mac", showDetails = false) + assertEquals("Needs you — build-bot", words.title) + assertEquals("Has a question", words.text) + assertNull(words.bigText) + assertCarriesNothingOf(question, words) + } + + @Test + fun `by default a finished turn does not show the agent's last message`() { + val words = InboxNotificationText.of(done, "build-bot", "Studio Mac", showDetails = false) + assertEquals("Completed — build-bot", words.title) + assertEquals("Finished", words.text) + assertNull(words.bigText) + assertCarriesNothingOf(done, words) + + val interrupted = InboxNotificationText.of(stopped, "build-bot", "Studio Mac", showDetails = false) + assertEquals("Completed — build-bot", interrupted.title) + assertEquals("Interrupted", interrupted.text) + assertCarriesNothingOf(stopped, interrupted) + } + + @Test + fun `the public version names only the session and the computer, with or without details`() { + for (ev in listOf(bash, write, bare, question, done, stopped)) { + for (details in listOf(false, true)) { + val words = InboxNotificationText.of(ev, "build-bot", "Studio Mac", showDetails = details) + assertEquals(words.title, words.publicTitle) + assertEquals("Studio Mac", words.publicText) + for (secret in listOfNotNull(ev.title, ev.detail).filterNot { ev.kind == InboxKind.DONE && it == ev.title }) { + assertFalse(words.publicTitle.contains(secret) || words.publicText.contains(secret), "public carries `$secret`") + } + } + } + val attention = InboxNotificationText.of(bash, "build-bot", "Studio Mac", showDetails = true) + assertEquals("Needs you — build-bot", attention.publicTitle) + assertEquals("Completed — build-bot", InboxNotificationText.of(done, "build-bot", "Studio Mac", true).publicTitle) + } + + @Test + fun `with details on, the notification carries the event's text as before`() { + val words = InboxNotificationText.of(bash, "build-bot", "Studio Mac", showDetails = true) + assertEquals("Needs you — build-bot", words.title) + assertEquals("Run command — ${bash.detail}", words.text) + assertEquals("Run command\n${bash.detail}", words.bigText) + + val q = InboxNotificationText.of(question, "build-bot", "Studio Mac", showDetails = true) + assertEquals(question.title, q.text) + assertEquals(question.title, q.bigText) + + val d = InboxNotificationText.of(done, "build-bot", "Studio Mac", showDetails = true) + assertEquals("Finished — ${done.detail}", d.text) + assertEquals("Finished\n${done.detail}", d.bigText) + } + + @Test + fun `with details on but no text in the event, the kind is said instead of nothing`() { + val empty = event(InboxKind.APPROVAL, "", " ") + val words = InboxNotificationText.of(empty, "build-bot", "Studio Mac", showDetails = true) + assertEquals("Needs approval", words.text) + assertNull(words.bigText) + } + + @Test + fun `a session with no name is called Session`() { + for (name in listOf(null, "", " ")) { + val words = InboxNotificationText.of(bash, name, "Studio Mac", showDetails = false) + assertEquals("Needs you — ${InboxNotificationText.FALLBACK_SESSION}", words.title) + assertEquals(words.title, words.publicTitle) + } + } + + private val notifier get() = AppSourcePins.app("notify/InboxNotifier.kt") + + @Test + fun `the notification takes every word from InboxNotificationText and always sets the public version`() { + val build = AppSourcePins.blockAfter(notifier, "private fun build(") + // The pre-fix builder wrote `ev.title + ev.detail` into the text and the BigTextStyle. + assertFalse(build.contains("ev.title"), "the notification reads the event's title itself:\n$build") + assertFalse(build.contains("ev.detail"), "the notification reads the event's detail itself:\n$build") + AppSourcePins.assertInOrder( + build, + "val words = InboxNotificationText.of(ev, session, host.name, showDetails)", + "val publicVersion = NotificationCompat.Builder(context, channel)", + ".setContentTitle(words.publicTitle)", + ".setContentText(words.publicText)", + ".build()", + ".setContentTitle(words.title)", + ".setContentText(words.text)", + ".setVisibility(NotificationCompat.VISIBILITY_PRIVATE)", + ".setPublicVersion(publicVersion)", + "words.bigText?.let { builder.setStyle(NotificationCompat.BigTextStyle().bigText(it)) }" + ) + val announce = AppSourcePins.blockAfter(notifier, "fun announce(") + AppSourcePins.assertInOrder( + announce, + "val showDetails = graph.hosts.notificationDetails", + "build(context, host, snapshot, ev, showDetails, quiet)" + ) + } + + @Test + fun `the details setting is off until the user turns it on in Settings`() { + val store = AppSourcePins.app("data/HostStore.kt") + assertTrue(store.contains("""get() = prefs.getBoolean("notifyDetails", false)"""), "the setting does not default to off") + val settings = AppSourcePins.ui("SettingsScreen.kt") + assertTrue(settings.contains("var notifyDetails by remember { mutableStateOf(graph.hosts.notificationDetails) }")) + AppSourcePins.assertInOrder( + settings, + "Text(\"Show details in notifications\")", + "Switch(checked = notifyDetails", + "graph.hosts.notificationDetails = it" + ) + // Stored only from the switch. + assertEquals(1, Regex("""graph\.hosts\.notificationDetails\s*=(?!=)""").findAll(settings).count()) + // Enabled exactly when the Notifications switch reads On (the review of A52): with notifications + // turned off for the app in the system, the details switch is not offered either. + assertTrue(settings.contains("Switch(checked = notify && canPost, onCheckedChange = {")) + assertTrue(settings.contains("Switch(checked = notifyDetails, enabled = notify && canPost, onCheckedChange = {")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InputBarTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InputBarTest.kt new file mode 100644 index 0000000000..10b46eb0af --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InputBarTest.kt @@ -0,0 +1,42 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.InputBar +import dev.nodeterm.protocol.model.InputBar.Send +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The terminal input bar's Send (audits A34, A41). TerminalController is only type-checked; it sends + * what [InputBar.plan] says, clears Ctrl only when something was sent, and tells the screen to clear + * the draft only then. + */ +class InputBarTest { + @Test + fun `nothing is sent while no stream is attached, so the draft is kept`() { + assertEquals(Send.NotAttached, InputBar.plan(attached = false, ctrlArmed = false, text = "ls -la", enter = true)) + assertEquals(Send.NotAttached, InputBar.plan(attached = false, ctrlArmed = false, text = "", enter = true)) + } + + @Test + fun `an armed Ctrl is not spent while no stream is attached`() { + // Not Control("\u001a"): that would disarm the chip for a byte that reaches nothing. + assertEquals(Send.NotAttached, InputBar.plan(attached = false, ctrlArmed = true, text = "z", enter = true)) + } + + @Test + fun `an attached stream gets the text as a paste, then Enter`() { + assertEquals(Send.Paste("ls -la", true), InputBar.plan(attached = true, ctrlArmed = false, text = "ls -la", enter = true)) + // An empty bar still sends the Enter. + assertEquals(Send.Paste("", true), InputBar.plan(attached = true, ctrlArmed = false, text = "", enter = true)) + } + + @Test + fun `Ctrl and one character is that control byte alone`() { + assertEquals(Send.Control("\u001a"), InputBar.plan(attached = true, ctrlArmed = true, text = "z", enter = true)) + } + + @Test + fun `Ctrl and anything else is sent as typed`() { + assertEquals(Send.Paste("zz", true), InputBar.plan(attached = true, ctrlArmed = true, text = "zz", enter = true)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt new file mode 100644 index 0000000000..4d0273f79b --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt @@ -0,0 +1,234 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +/** + * Runs `src/test/interop/host-fixture.ts` — the DESKTOP's own relay host / pairing code — in a node + * child process. Skips (never fails) when this checkout has no node or no `npm ci` yet, so a + * JVM-only contributor still gets a green protocol build; CI installs both and runs everything. + * + * The bundle aliases `electron` to `src/test/interop/electron-stub.ts` (audit A60): the real package's + * `require` downloads the Electron binary when it is missing, as it is after any fresh `npm ci` (the + * package has no install script), and that download ran inside the 20 s ready wait. + * [InteropHarnessTest] pins the alias and the kill of a process whose ready wait failed. + * + * Every process it starts is `node` itself, bundling included (audit A70): npm's `.bin` shims are sh + * scripts on Windows, which Java's ProcessBuilder (CreateProcess) cannot run. + */ +class InteropHarness private constructor(private val process: Process) : AutoCloseable { + private val lines = LinkedBlockingQueue() + private val seen = ArrayList() + lateinit var ready: JsonObject + private set + + private fun startReader() { + Thread({ + process.inputStream.bufferedReader().forEachLine { line -> + val obj = runCatching { Json.parseToJsonElement(line).jsonObject }.getOrNull() + if (obj != null) lines.put(obj) else System.err.println("[fixture] $line") + } + }, "interop-stdout").apply { isDaemon = true }.start() + Thread({ + process.errorStream.bufferedReader().forEachLine { System.err.println("[fixture:err] $it") } + }, "interop-stderr").apply { isDaemon = true }.start() + } + + /** Wait for the next event matching [predicate] (earlier non-matching events are kept for later waits). */ + fun await(timeoutMs: Long = 10_000, predicate: (JsonObject) -> Boolean): JsonObject { + synchronized(seen) { + seen.firstOrNull(predicate)?.let { + seen.remove(it) + return it + } + } + val deadline = System.currentTimeMillis() + timeoutMs + while (true) { + val left = deadline - System.currentTimeMillis() + if (left <= 0) throw AssertionError("fixture event not seen within ${timeoutMs}ms; saw: ${synchronized(seen) { seen.toList() }}") + val next = lines.poll(left, TimeUnit.MILLISECONDS) ?: continue + if (next["event"]?.toString() == "\"fatal\"") throw AssertionError("fixture died: $next") + if (predicate(next)) return next + synchronized(seen) { seen.add(next) } + } + } + + internal fun command(line: String) { + process.outputStream.write((line + "\n").toByteArray(Charsets.UTF_8)); process.outputStream.flush() + } + + fun awaitEvent(name: String, timeoutMs: Long = 10_000): JsonObject = + await(timeoutMs) { it["event"]?.toString() == "\"$name\"" } + + /** Call only after an awaited producer control barrier; inspect every earlier leftover event. */ + internal fun drainEvents(): List = synchronized(seen) { + ArrayList(seen).also { out -> seen.clear(); lines.drainTo(out) } + } + + override fun close() { + // Taken before node exits: after that its children are re-parented and unreachable from here. + val descendants = process.descendants().toList() + process.destroy() + if (!process.waitFor(3, TimeUnit.SECONDS)) process.destroyForcibly() + descendants.forEach { it.destroy() } + } + + companion object { + val repoRoot: File = File(System.getProperty("nodeterm.repoRoot") ?: "../..").canonicalFile + + /** Where the fixture's `electron` import resolves to instead of the npm package. */ + const val ELECTRON_STUB = "android/protocol/src/test/interop/electron-stub.ts" + + /** Bundles host-fixture.ts through esbuild's JS API; see its header for why not the CLI (audit A70). */ + const val BUNDLER = "android/protocol/src/test/interop/bundle-fixture.cjs" + + /** + * The bundle step's argv. Its program is `node`, never a file under node_modules: npm's + * `.bin/esbuild` is a sh shim on Windows, which CreateProcess cannot run, so every interop test + * errored there instead of running (audit A70). `node` resolves to node.exe on PATH. + */ + internal fun bundleCommand(out: File): List = listOf("node", BUNDLER, out.path, ELECTRON_STUB) + + internal val bundle: File by lazy { + val out = File(repoRoot, "android/protocol/build/interop/host-fixture.cjs") + File(out.path + ".meta.json").delete() // never read a previous build's metafile + val proc = ProcessBuilder(bundleCommand(out)).directory(repoRoot).redirectErrorStream(true).start() + val log = proc.inputStream.bufferedReader().readText() + check(proc.waitFor() == 0) { "esbuild failed: $log" } + out + } + + /** The ack filesystem contract has no relay sockets/crypto dependencies. */ + private val ackBundle: File by lazy { + val out = File(repoRoot, "android/protocol/build/interop/ack-fixture.cjs") + val proc = ProcessBuilder(bundleCommand(out) + "android/protocol/src/test/interop/ack-fixture-runner.ts") + .directory(repoRoot).redirectErrorStream(true).start() + val log = proc.inputStream.bufferedReader().readText() + check(proc.waitFor() == 0) { "esbuild failed: $log" } + out + } + + internal val projectLaunchBundle: File by lazy { + val out = File(repoRoot, "android/protocol/build/interop/project-launch-fixture.cjs") + val proc = ProcessBuilder(bundleCommand(out) + "android/protocol/src/test/interop/project-launch-fixture.ts") + .directory(repoRoot).redirectErrorStream(true).start() + val log = proc.inputStream.bufferedReader().readText() + check(proc.waitFor() == 0) { "esbuild failed: $log" } + out + } + + /** Actual selected-profile SSH file service and WorkspaceStore; no relay sockets. */ + internal val sshActionsBundle: File by lazy { + val out = File(repoRoot, "android/protocol/build/interop/ssh-actions-fixture.cjs") + File(out.path + ".meta.json").delete() + val proc = ProcessBuilder(bundleCommand(out) + "android/protocol/src/test/interop/ssh-actions-fixture.ts") + .directory(repoRoot).redirectErrorStream(true).start() + val log = proc.inputStream.bufferedReader().readText() + check(proc.waitFor() == 0) { "esbuild failed: $log" } + out + } + internal val sshActionsBundleMeta: File by lazy { File(sshActionsBundle.path + ".meta.json") } + + /** Actual selected Server config/platform/store/mirror, with a private OS-home abstraction. */ + internal val serverProfileBundle: File by lazy { + val out = File(repoRoot, "android/protocol/build/interop/server-profile-fixture.cjs") + File(out.path + ".meta.json").delete() + val proc = ProcessBuilder(bundleCommand(out) + "android/protocol/src/test/interop/server-profile-fixture.ts") + .directory(repoRoot).redirectErrorStream(true).start() + val log = proc.inputStream.bufferedReader().readText() + check(proc.waitFor() == 0) { "esbuild failed: $log" } + out + } + internal val serverProfileBundleMeta: File by lazy { File(serverProfileBundle.path + ".meta.json") } + + /** Actual public relay-advertisement writer/remover, with a fixture-only OS-home adapter. */ + internal val relayAdvertisementBundle: File by lazy { + val out = File(repoRoot, "android/protocol/build/interop/relay-advertisement-fixture.cjs") + File(out.path + ".meta.json").delete() + val proc = ProcessBuilder(bundleCommand(out) + "android/protocol/src/test/interop/relay-advertisement-fixture.ts") + .directory(repoRoot).redirectErrorStream(true).start() + val log = proc.inputStream.bufferedReader().readText() + check(proc.waitFor() == 0) { "esbuild failed: $log" } + out + } + internal val relayAdvertisementBundleMeta: File by lazy { File(relayAdvertisementBundle.path + ".meta.json") } + + /** Actual managed host transaction with an explicit native-process recorder boundary. */ + internal val managedSessionBundle: File by lazy { + val out = File(repoRoot, "android/protocol/build/interop/managed-session-fixture.cjs") + File(out.path + ".meta.json").delete() + val proc = ProcessBuilder(bundleCommand(out) + "android/protocol/src/test/interop/managed-session-fixture.ts") + .directory(repoRoot).redirectErrorStream(true).start() + val log = proc.inputStream.bufferedReader().readText() + check(proc.waitFor() == 0) { "esbuild failed: $log" } + out + } + internal val managedSessionBundleMeta: File by lazy { File(managedSessionBundle.path + ".meta.json") } + + /** + * esbuild's metafile for [bundle], which the bundler writes beside it: its `inputs` are the files + * the bundle was built from, repo-relative and `/`-separated (audit A63, [WorkflowPathFilterTest]). + */ + internal val bundleMeta: File by lazy { File(bundle.path + ".meta.json") } + internal val ackBundleMeta: File by lazy { File(ackBundle.path + ".meta.json") } + internal val projectLaunchBundleMeta: File by lazy { File(projectLaunchBundle.path + ".meta.json") } + + /** + * Home-directory variables pointing a fixture at [home]. `os.homedir()` reads HOME on POSIX and + * USERPROFILE on Windows, and pair mode writes `.nodeterm/agent.json` (a device with a live bearer + * token) and `.ssh/authorized_keys` under it: with HOME alone, a Windows run would write into the + * contributor's real profile (audit A70). FIXTURE_HOME is what the fixture checks `os.homedir()` + * against before pair mode writes anything. + */ + fun scratchHomeEnv(home: File): Map = + mapOf("HOME" to home.path, "USERPROFILE" to home.path, "FIXTURE_HOME" to home.path) + + internal fun available(mode: String = "relay"): Boolean { + val node = runCatching { ProcessBuilder("node", "--version").start().waitFor() == 0 }.getOrDefault(false) + // The esbuild PACKAGE (its JS API), not the .bin shim the harness no longer runs. + return node && File(repoRoot, "node_modules/esbuild/package.json").exists() && + (mode == "ack-sweep" || mode == "ssh-actions" || mode == "managed-session" || mode == "server-profile" || mode == "relay-advertisement" || (File(repoRoot, "node_modules/ws").exists() && + File(repoRoot, "node_modules/tweetnacl").exists())) + } + + /** + * Starts the fixture in [mode] and waits for its ready line. When that wait fails (a timeout, or + * a `fatal` event), the process is killed before the error propagates: the caller never gets a + * handle, so nothing else would ever close it. [onSpawn] is a test seam for exactly that check. + */ + fun start( + mode: String, + env: Map = emptyMap(), + readyTimeoutMs: Long = 20_000, + onSpawn: (Process) -> Unit = {} + ): InteropHarness { + assumeTrue(available(mode), "node + repo node_modules (npm ci) are needed for interop tests") + val fixture = when (mode) { + "ack-sweep" -> ackBundle + "project-launch" -> projectLaunchBundle + "ssh-actions" -> sshActionsBundle + "managed-session" -> managedSessionBundle + "server-profile" -> serverProfileBundle + "relay-advertisement" -> relayAdvertisementBundle + else -> bundle + } + val pb = ProcessBuilder("node", fixture.path, mode).directory(repoRoot) + pb.environment().putAll(env) + val h = InteropHarness(pb.start()) + try { + onSpawn(h.process) + h.startReader() + h.ready = h.await(readyTimeoutMs) { it["ready"] != null } + } catch (t: Throwable) { + h.close() + throw t + } + return h + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarnessTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarnessTest.kt new file mode 100644 index 0000000000..3a9c527922 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarnessTest.kt @@ -0,0 +1,90 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The interop harness itself (audits A60, A70). The fixture must not load the real `electron` package, + * whose first `require` downloads the Electron binary when it is missing (after any fresh `npm ci`), + * inside the harness's ready wait. A start whose ready wait fails must not leave the node process + * behind: the caller never receives a handle to close. And the harness must run on Windows too: it + * spawns only `node`, and a scratch home covers the home variable Windows reads. + */ +class InteropHarnessTest { + private val needs = "node + repo node_modules (npm ci) are needed for interop tests" + + @Test + fun `managed launch fixture uses real settings trust and spawn producers with scoped native boundaries`() { + assumeTrue(InteropHarness.available(), needs) + val source = InteropHarness.projectLaunchBundle.readText() + val inputs = Json.parseToJsonElement(InteropHarness.projectLaunchBundleMeta.readText()) + .jsonObject["inputs"]!!.jsonObject.keys + for (input in listOf("src/core/pty-manager.ts", "src/core/workspace-store.ts", + "src/core/project-spawn-overrides.ts", "src/core/project-trust-store.ts", + "src/main/remote/host-service.ts", "src/main/remote/host-new-sessions.ts", + "android/protocol/src/test/interop/launch-os.ts", "android/protocol/src/test/interop/launch-native.ts")) { + assertTrue(input in inputs, "managed launch fixture omits its real producer/boundary: $input") + } + assertFalse(Regex("""require\(\s*["'](?:node-pty|os)["']\s*\)""").containsMatchIn(source), + "managed launch fixture escaped its native/profile isolation") + assertTrue(source.contains("managed launch fixture refused a subprocess"), + "managed launch fixture lacks its fail-closed manager subprocess boundary") + } + + @Test + fun `the bundle step runs node, never an npm shim`() { + // A70: node_modules/.bin/esbuild is a sh script on Windows, which CreateProcess cannot run, so + // the bundle step threw and every interop test errored there instead of skipping or running. + val argv = InteropHarness.bundleCommand(File("out.cjs")) + assertEquals("node", argv.first(), "the bundle step must spawn node: $argv") + assertTrue(argv.none { it.contains("node_modules") }, "the bundle step names a node_modules file: $argv") + assertTrue(File(InteropHarness.repoRoot, argv[1]).isFile, "no bundler script at ${argv[1]}") + } + + @Test + fun `a scratch home points the home variable of every OS at the dir`() { + // A70: os.homedir() reads HOME on POSIX and USERPROFILE on Windows; pair mode writes a live + // bearer token under it, so a run must never fall through to the contributor's real profile. + val home = File("scratch-home") + val env = InteropHarness.scratchHomeEnv(home) + for (name in listOf("HOME", "USERPROFILE", "FIXTURE_HOME")) assertEquals(home.path, env[name], name) + } + + @Test + fun `the fixture bundle resolves electron to the stub and never requires the package`() { + assumeTrue(InteropHarness.available(), needs) + val source = InteropHarness.bundle.readText() + assertFalse( + Regex("""require\(\s*["']electron["']\s*\)""").containsMatchIn(source), + "the bundle requires the real electron package; alias it to ${InteropHarness.ELECTRON_STUB}" + ) + assertTrue( + source.contains("is not available in the Android interop fixture"), + "the bundle does not carry ${InteropHarness.ELECTRON_STUB}" + ) + } + + @Test + fun `a start whose ready wait fails kills the fixture process`() { + assumeTrue(InteropHarness.available(), needs) + var spawned: Process? = null + try { + val err = assertFailsWith { + InteropHarness.start("never-ready", readyTimeoutMs = 500, onSpawn = { spawned = it }) + } + assertTrue(err.message.orEmpty().contains("not seen within 500ms"), err.message) + val process = assertNotNull(spawned, "the fixture was never spawned") + assertFalse(process.isAlive, "a failed start left the node process running") + } finally { + spawned?.destroyForcibly() + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeyboardInsetsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeyboardInsetsTest.kt new file mode 100644 index 0000000000..c6649bb654 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeyboardInsetsTest.kt @@ -0,0 +1,108 @@ +package dev.nodeterm.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A77: the app targets API 35, so on Android 15 its window is edge-to-edge and the keyboard no longer + * resizes it; the keyboard's inset reaches Compose instead. The terminal screen applied the Scaffold's + * padding and then `imePadding()` without consuming that padding, so with the keyboard up the + * navigation bar was counted twice (the keyboard's inset already includes it). Pair and Settings, + * whose text fields sit in a scrolling column, had no keyboard padding at all, so the keyboard could + * cover the field being typed in. + * + * Insets, the keyboard and a Scaffold cannot run on a JVM, so the rule is pinned in the app's source + * ([AppSourcePins]): one helper (`aboveKeyboard`) consumes the Scaffold's padding before it adds the + * keyboard's inset, no other code asks for that inset, and every Scaffold body that holds a text field + * uses the helper, before its scroll. How the screens look with the keyboard up is a device check. + */ +class KeyboardInsetsTest { + private val helper = "fun Modifier.aboveKeyboard(" + + /** + * Every Kotlin source in the app, by its path under the app's package root, without its block + * comments and whole-line comments: what the code does, not what its comments mention. + */ + private fun appSources(): Map = + AppSourcePins.appSrc.walkTopDown().filter { it.isFile && it.extension == "kt" } + .associate { it.relativeTo(AppSourcePins.appSrc).invariantSeparatorsPath to withoutComments(it.readText()) } + + private fun withoutComments(source: String): String = + source.replace(Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL), "") + .replace(Regex("""(?m)^\s*//.*$"""), "") + + /** The body of the braces that open at [open] in [source], braces balanced. */ + private fun braces(source: String, open: Int): String = AppSourcePins.blockAfter(source.substring(open), "{") + + /** + * Each Scaffold's content lambda in [source]: its parameter name and its body. The content is the + * trailing lambda, the first `) { name ->` after `Scaffold(`. + */ + private fun scaffoldBodies(source: String): List> = + Regex("""\bScaffold\(""").findAll(source).map { call -> + val content = Regex("""\)\s*\{\s*(\w+)\s*->""").find(source, call.range.last) + ?: error("a Scaffold without a content lambda") + content.groupValues[1] to braces(source, content.range.first + content.value.indexOf('{')) + }.toList() + + @Test + fun `the helper consumes the Scaffold's padding before it adds the keyboard's inset`() { + val insets = AppSourcePins.ui("Insets.kt") + val at = insets.indexOf(helper) + assertTrue(at >= 0, "Insets.kt must define $helper") + val body = insets.substring(at) + AppSourcePins.assertInOrder( + body, + "padding(scaffoldPadding)", + "consumeWindowInsets(scaffoldPadding)", + "imePadding()" + ) + } + + @Test + fun `nothing but the helper asks for the keyboard's inset`() { + // A bare `imePadding()` after a Scaffold's padding counts the navigation bar twice; one in a + // dialog's content can add the keyboard to a window the system already keeps clear of it. + val callers = appSources().flatMap { (path, source) -> + Regex("""\bimePadding\(\)|WindowInsets\.ime\b""").findAll(source).map { path }.toList() + } + assertEquals(listOf("ui/Insets.kt"), callers) + } + + @Test + fun `every Scaffold body that holds a text field makes room for the keyboard, before its scroll`() { + val withField = mutableListOf() + for ((path, source) in appSources()) { + for ((param, body) in scaffoldBodies(source)) { + if (!body.contains("TextField(")) continue + withField += path + AppSourcePins.assertInOrder(body, ".aboveKeyboard($param)") + assertTrue( + !Regex("""\.padding\($param\)""").containsMatchIn(body), + "$path applies the Scaffold's padding itself as well as through aboveKeyboard" + ) + if (body.contains("verticalScroll(")) AppSourcePins.assertInOrder(body, ".aboveKeyboard($param)", ".verticalScroll(") + } + } + // The scan finds the screens it exists for, so it cannot pass by finding nothing. + assertTrue( + withField.containsAll(listOf("ui/TerminalScreen.kt", "ui/PairScreen.kt", "ui/SettingsScreen.kt")), + "Scaffold bodies with a text field: $withField" + ) + } + + @Test + fun `below Android 15 the window stays off the inset path`() { + // On API 26-34 the decor consumes the bars' and the keyboard's insets and adjustResize resizes + // the window, so the screens work there without them. enableEdgeToEdge() (or turning off + // decorFitsSystemWindows) would move those phones onto the inset path as well: a change for + // every screen on every phone, not part of this fix. + val sources = appSources() + assertTrue(sources.isNotEmpty()) + for ((path, source) in sources) { + assertTrue(!source.contains("enableEdgeToEdge("), "$path calls enableEdgeToEdge()") + assertTrue(!source.contains("setDecorFitsSystemWindows("), "$path changes decorFitsSystemWindows") + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeysTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeysTest.kt new file mode 100644 index 0000000000..5cfecc5fe5 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/KeysTest.kt @@ -0,0 +1,27 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.Keys +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** A34: the Ctrl chip's byte, shared by typed keystrokes and the input bar. */ +class KeysTest { + @Test + fun `single characters map to their control bytes in either case`() { + assertEquals("\u001a", Keys.ctrl("z")) + assertEquals("\u001a", Keys.ctrl("Z")) + assertEquals("\u0003", Keys.ctrl("c")) + assertEquals("\u001b", Keys.ctrl("[")) + assertEquals("\u0000", Keys.ctrl("@")) + assertEquals("\u007f", Keys.ctrl("?")) + } + + @Test + fun `anything else has no control byte`() { + assertNull(Keys.ctrl("")) + assertNull(Keys.ctrl("zz")) + assertNull(Keys.ctrl("1")) + assertNull(Keys.ctrl("é")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LanRefreshTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LanRefreshTest.kt new file mode 100644 index 0000000000..a712454783 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LanRefreshTest.kt @@ -0,0 +1,246 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.pairing.RelayBlock +import dev.nodeterm.protocol.ssh.LanRefresh +import dev.nodeterm.protocol.ssh.LanReport +import dev.nodeterm.protocol.ssh.SshFallback +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A74-refresh: a relay listing tells the phone the computer's current LAN address and SSH host + * keys, and the phone updates the paired record from it, never from anything it got over SSH. The pin + * gives way only to a key the SSH leg was refused and the computer confirms (review of A74-refresh). + * The desktop's side and the whole round trip through the relay are in RelayInteropTest; these are the + * rules, which need neither node nor a server. + */ +class LanRefreshTest { + // GitHub's published host-key fingerprints (as in HostKeyAnchorsTest): well-formed, nothing more. + private val fpA = "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU" + private val fpB = "SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM" + private val fpC = "SHA256:" + "C".repeat(43) + + private val boxKey = java.util.Base64.getEncoder().encodeToString(ByteArray(32) { 7 }) + private val paired = PairedHost( + id = "dev-1", name = "Box", host = "192.168.1.5", port = 22, user = "me", sshAvailable = true, + hostKeyB64 = boxKey, relay = RelayBlock("room", boxKey, "wss://relay.example.test"), + sshHostKeyFingerprint = fpA, pairedAt = 1, sshHostKeyAnchors = listOf(fpA) + ) + + private fun parse(json: String) = LanReport.parse(Json.parseToJsonElement(json)) + private fun relayListing(report: LanReport?) = ProjectsSnapshot.EMPTY.copy(lan = report) + + @Test + fun `the report is read field by field, and an unusable one is no report`() { + assertEquals(LanReport("192.168.1.42", listOf(fpA, fpB)), parse("""{"host":"192.168.1.42","sshHostKeyFingerprints":["$fpA","$fpB"]}""")) + assertEquals(LanReport(null, listOf(fpA)), parse("""{"sshHostKeyFingerprints":["$fpA","junk"]}""")) + assertEquals(LanReport("10.0.0.9", emptyList()), parse("""{"host":"10.0.0.9","sshHostKeyFingerprints":"$fpA"}""")) + // An older desktop sends none; a broken field is the same as none. + assertNull(LanReport.parse(null)) + assertNull(parse("""{}""")) + assertNull(parse(""""192.168.1.42"""")) + assertNull(parse("""{"host":42,"sshHostKeyFingerprints":[7]}""")) + } + + @Test + fun `only an address the LAN leg can dial is taken`() { + for (ok in listOf("192.168.1.42", "10.0.0.9", "172.16.4.1", "100.64.0.7", "8.8.8.8", "223.255.255.254")) { + assertTrue(LanReport.isDialableIPv4(ok), ok) + } + // Loopback would dial the phone itself; the rest the desktop's own pick never returns. + for (bad in listOf( + "127.0.0.1", "127.1.2.3", "0.0.0.0", "0.1.2.3", "169.254.10.2", "224.0.0.1", "255.255.255.255", + "192.168.001.5", "192.168.1", "192.168.1.256", "1.2.3.4.5", "fe80::1", "::1", "box.local", " 192.168.1.5", "" + )) { + assertFalse(LanReport.isDialableIPv4(bad), bad) + assertNull(parse("""{"host":"$bad"}"""), bad) + } + } + + @Test + fun `a listing that came over SSH never refreshes the facts that check SSH`() { + val report = LanReport("10.0.0.9", listOf(fpB)) + assertNull(LanRefresh.afterListing(paired, TransportKind.SSH, relayListing(report), refusedHostKey = fpB)) + // Over the relay the same listing does. + assertNotNull(LanRefresh.afterListing(paired, TransportKind.RELAY, relayListing(report), refusedHostKey = fpB)) + // No report (an older desktop): nothing to do. + assertNull(LanRefresh.afterListing(paired, TransportKind.RELAY, relayListing(null), refusedHostKey = fpB)) + } + + @Test + fun `a new address replaces the one the QR carried, and the same address changes nothing`() { + val moved = assertNotNull(LanRefresh.apply(paired, LanReport("192.168.1.77", emptyList()), refusedHostKey = null)) + assertEquals("192.168.1.77", moved.host.host) + assertTrue(moved.addressChanged) + assertNull(moved.confirmedKey) + // Everything else is what it was: the pin, the anchors, the relay leg, the user and port. + assertEquals(paired.copy(host = "192.168.1.77"), moved.host) + assertEquals(paired, moved.previous) + assertNull(LanRefresh.apply(paired, LanReport("192.168.1.5", emptyList()), refusedHostKey = null)) + assertNull(LanRefresh.apply(paired, LanReport("192.168.1.5", listOf(fpA)), refusedHostKey = null), "the record already says it all") + } + + @Test + fun `reported keys that include the pin keep it, and become the anchors`() { + val r = assertNotNull(LanRefresh.apply(paired, LanReport(null, listOf(fpB, fpA)), refusedHostKey = null)) + assertEquals(fpA, r.host.sshHostKeyFingerprint) + assertEquals(listOf(fpB, fpA), r.host.sshHostKeyAnchors) + assertNull(r.confirmedKey) + assertFalse(r.addressChanged) + assertNull(LanRefresh.note(r), "nothing the user would act on changed") + } + + /** + * Review of A74-refresh: the report is what nodeterm on the computer could READ of its sshd's keys, + * not what sshd serves. A pin the phone never saw fail works, so a report that does not name it + * (the reader misses the served key, or the pin is a host certificate's an older build took) must + * not drop it, nor say that the key changed. + */ + @Test + fun `a pin missing from the reported keys stays while the phone was never refused another key`() { + val r = assertNotNull(LanRefresh.apply(paired, LanReport(null, listOf(fpB, fpC)), refusedHostKey = null)) + assertEquals(fpA, r.host.sshHostKeyFingerprint, "a pin that works is kept") + assertEquals(listOf(fpB, fpC), r.host.sshHostKeyAnchors) + assertNull(r.confirmedKey) + assertNull(LanRefresh.note(r), "nothing changed for the next connect, so nothing is said") + // Nor when the key the SSH leg was refused is not the computer's: another machine at the address. + val stranger = "SHA256:" + "Z".repeat(43) + val other = assertNotNull(LanRefresh.apply(paired, LanReport(null, listOf(fpB, fpC)), refusedHostKey = stranger)) + assertEquals(fpA, other.host.sshHostKeyFingerprint) + assertNull(other.confirmedKey) + } + + @Test + fun `a refused key the computer confirms replaces the pin, so the next connect must present one of the reported keys`() { + val r = assertNotNull(LanRefresh.apply(paired, LanReport(null, listOf(fpB, fpC)), refusedHostKey = fpB)) + assertNull(r.host.sshHostKeyFingerprint, "the old key is no longer trusted") + assertEquals(listOf(fpB, fpC), r.host.sshHostKeyAnchors, "and the next connect is anchored, not trust on first use") + assertEquals(fpB, r.confirmedKey) + // Survives the phone's own record. + assertEquals(r.host, PairedHost.fromJson(r.host.toJson())) + // Said once: the next listing over the same relay connection finds nothing left to change. + assertNull(LanRefresh.apply(r.host, LanReport(null, listOf(fpB, fpC)), refusedHostKey = fpB)) + } + + /** + * Review of A74-refresh: the desktop reads every `ssh_host_*_key.pub` on disk, served or not. When + * sshd stops serving the pinned key (its `HostKey` line removed, `HostKeyAlgorithms` narrowed, a FIPS + * mode without ed25519) while the `.pub` stays, the pin is still among the reported keys, and the + * server presents another of them: that is the key the computer confirms, and it must win. + */ + @Test + fun `a pin still among the reported keys is replaced when the computer confirms the key it was refused`() { + val r = assertNotNull(LanRefresh.apply(paired, LanReport(null, listOf(fpA, fpB)), refusedHostKey = fpB)) + assertNull(r.host.sshHostKeyFingerprint) + assertEquals(listOf(fpA, fpB), r.host.sshHostKeyAnchors) + assertEquals(fpB, r.confirmedKey) + } + + @Test + fun `no reported keys leave the pin and the anchors alone`() { + assertNull(LanRefresh.apply(paired, LanReport(null, emptyList()), refusedHostKey = fpB)) + val moved = assertNotNull(LanRefresh.apply(paired, LanReport("10.0.0.9", emptyList()), refusedHostKey = fpB)) + assertEquals(fpA, moved.host.sshHostKeyFingerprint) + assertEquals(listOf(fpA), moved.host.sshHostKeyAnchors) + assertNull(moved.confirmedKey) + // A computer paired with an older desktop (trust on first use, no anchors) keeps its pin too. + val tofu = paired.copy(sshHostKeyAnchors = emptyList()) + assertNull(LanRefresh.apply(tofu, LanReport(null, emptyList()), refusedHostKey = null)) + } + + @Test + fun `a computer never connected over SSH gets the reported keys as the anchors of its first connect`() { + val fresh = paired.copy(sshHostKeyFingerprint = null, sshHostKeyAnchors = emptyList()) + val r = assertNotNull(LanRefresh.apply(fresh, LanReport(null, listOf(fpB)), refusedHostKey = null)) + assertNull(r.host.sshHostKeyFingerprint) + assertEquals(listOf(fpB), r.host.sshHostKeyAnchors) + assertNull(r.confirmedKey, "no key was refused") + // A first connect refused a key the pairing did not name (HostKeyNotPairedException), and the + // computer now names it: the anchors let it in, and the warning may say so. + val anchored = paired.copy(sshHostKeyFingerprint = null) + val confirmed = assertNotNull(LanRefresh.apply(anchored, LanReport(null, listOf(fpB)), refusedHostKey = fpB)) + assertNull(confirmed.host.sshHostKeyFingerprint) + assertEquals(listOf(fpB), confirmed.host.sshHostKeyAnchors) + assertEquals(fpB, confirmed.confirmedKey) + } + + @Test + fun `a computer with no LAN leg is left alone`() { + val report = LanReport("10.0.0.9", listOf(fpB)) + // Added by its SSH address: no relay, so no report is ever this computer's (audit A27). + assertNull(LanRefresh.apply(paired.copy(id = "ssh-1", manual = true, relay = null, hostKeyB64 = null), report, refusedHostKey = fpB)) + // Paired relay-only (a Windows desktop): there is no SSH leg to point anywhere. + assertNull(LanRefresh.apply(paired.copy(sshAvailable = false), report, refusedHostKey = fpB)) + } + + @Test + fun `the note says what changed for the next connect, both old and new address`() { + val both = assertNotNull(LanRefresh.apply(paired, LanReport("192.168.1.77", listOf(fpB)), refusedHostKey = fpB)) + val note = assertNotNull(LanRefresh.note(both)) + assertTrue(note.startsWith("Since then, "), note) + assertTrue(note.contains("192.168.1.77") && note.contains("192.168.1.5"), note) + assertTrue(note.contains("confirmed through the relay that the host key its SSH server presented ($fpB) is one of its own"), note) + assertTrue(note.contains("from the next connect"), note) + val addressOnly = assertNotNull(LanRefresh.note(assertNotNull(LanRefresh.apply(paired, LanReport("192.168.1.77", emptyList()), refusedHostKey = null)))) + assertFalse(addressOnly.contains("host key"), addressOnly) + // A report that merely does not name the pin claims nothing about the key (review of A74-refresh). + val unnamed = assertNotNull(LanRefresh.apply(paired, LanReport("192.168.1.77", listOf(fpB)), refusedHostKey = null)) + assertFalse(assertNotNull(LanRefresh.note(unnamed)).contains("host key")) + } + + @Test + fun `the changed-key advice names the relay's confirmation, and re-pairing as another way`() { + assertTrue(SshFallback.REPAIR_NOTE.contains("confirms it through the relay"), SshFallback.REPAIR_NOTE) + assertTrue(SshFallback.REPAIR_NOTE.contains("pair it again"), SshFallback.REPAIR_NOTE) + } + + /** + * The app's `HostSession` applies it, which only a device runs: pinned in the source instead. After + * the primary relay connect's first listing (once the SSH warning is set, so the note can join it) + * and after every listing the primary connection makes, always through [LanRefresh.afterListing] + * with the connection's own kind, and the key that connection's SSH leg was refused. Never from the + * relay held next to a live SSH connection. + */ + @Test + fun `the app refreshes the record after a primary relay listing, and only through the kind check`() { + val conn = AppSourcePins.app("conn/ConnectionManager.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(conn, "private suspend fun connectLocked(trigger: Trigger, lease: HostLifetime.Lease): HostConnection"), + "_snapshot.value = connected.first", + "_sshWarning.value = sshWarning", + "adopt(connected.connection, lease)", + "refreshLanLeg(connected.connection, connected.first)" + ) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(conn, "suspend fun refreshNow(trigger: Trigger = Trigger.AUTO)"), + "c.listProjects()", + "refreshLanLeg(c, it)", + "_snapshot.value = it" + ) + val viaRelay = AppSourcePins.blockAfter(conn.substring(conn.indexOf("suspend fun viaRelay(")), "return sideMutex.withLock") + assertFalse(viaRelay.contains("refreshLanLeg"), viaRelay) + val body = AppSourcePins.blockAfter(conn, "private fun refreshLanLeg(c: HostConnection, listed: ProjectsSnapshot)") + assertTrue(body.contains("LanRefresh.afterListing(before, c.kind, listed, refused)"), body) + assertTrue(body.contains("graph.hosts.updateCurrent(hostId, before.hostKeyB64, { lifetime.isCurrent(lease) && conn === c }) { current -> LanRefresh.afterListing(current, c.kind, listed, refused)?.host ?: current }"), body) + assertFalse(body.contains("LanRefresh.apply("), "the kind check must not be skipped") + // The refused key is this connection's own (review of A74-refresh): the one the SSH leg of the + // connect that opened it was refused, as SshFallback handed it on, never another connection's. + assertTrue(body.contains("val refused = sshRefusal?.takeIf { it.connection === c }?.hostKey"), body) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(conn, "private suspend fun connectLocked(trigger: Trigger, lease: HostLifetime.Lease): HostConnection"), + "refusedHostKey = next.refusedHostKey", + "_sshWarning.value = sshWarning", + "sshRefusal = refusedHostKey?.let { SshRefusal(connected.connection, it) }", + "refreshLanLeg(connected.connection, connected.first)" + ) + } +} + diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LegRoutingTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LegRoutingTest.kt new file mode 100644 index 0000000000..91fa727d08 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LegRoutingTest.kt @@ -0,0 +1,295 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.host.LegRouting.Leg +import dev.nodeterm.protocol.host.LegRouting.RelayLeg +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.ProjectInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Audit A26: on the LAN connection `Auto` picks first, New session, board edits and node actions + * were simply unavailable (the button hidden, the board read-only), although the same computer's + * relay leg could do them. One pure decision says which leg answers each verb. + */ +class LegRoutingTest { + /** What the direct-SSH transport serves (SshHostConnection.capabilities). */ + private val ssh = HostCapabilities( + boardWrites = false, git = true, nodeActions = false, registerNode = false, answerApprovals = true + ) + private val appVerbs = listOf(Capability.BOARD_WRITES, Capability.REGISTER_NODE, Capability.NODE_ACTIONS ) + + @Test + fun `on SSH the app's own verbs open the relay leg next to it`() { + for (cap in appVerbs) { + assertEquals(Leg.Relay, LegRouting.route(cap, TransportKind.SSH, ssh, RelayLeg.AVAILABLE), "$cap") + } + } + + @Test + fun `a project another desktop drives over SSH answers only what the machine does (A27)`() { + val driven = ProjectInfo("project-drv", "Driven", null, "/srv/drv", null, false, emptyList(), null, drivenRemotely = true) + val own = driven.copy(drivenRemotely = false) + for (cap in appVerbs) { + for (leg in listOf(Leg.Primary, Leg.Relay)) { + val refused = assertIs(LegRouting.forProject(cap, driven, leg), "$cap via $leg") + assertTrue(refused.reason.startsWith(cap.what) && refused.reason.contains("another computer"), refused.reason) + assertEquals(leg, LegRouting.forProject(cap, own, leg), "a project of this computer keeps the computer's answer") + assertEquals(leg, LegRouting.forProject(cap, null, leg)) + } + } + // A held approval is a file on THIS computer, written over SSH: still the primary leg. + assertEquals(Leg.Primary, LegRouting.forProject(Capability.ANSWER_APPROVALS, driven, Leg.Primary)) + // Never "turn on remote access": the relay this phone holds is this computer's, not that desktop's. + assertFalse(LegRouting.drivenElsewhere(Capability.NODE_ACTIONS).contains("remote access")) + } + + @Test + fun `what SSH does itself never opens the relay`() { + assertEquals(Leg.Primary, LegRouting.route(Capability.ANSWER_APPROVALS, TransportKind.SSH, ssh, RelayLeg.AVAILABLE)) + for (relay in RelayLeg.entries) assertEquals(Leg.Primary, LegRouting.route(Capability.GIT, TransportKind.SSH, ssh, relay)) + val driven = ProjectInfo("driven", "Driven", null, "/work/app", null, false, emptyList(), null, drivenRemotely = true) + assertEquals(Leg.Primary, LegRouting.forProject(Capability.GIT, driven, Leg.Primary)) + assertIs(LegRouting.forProject(Capability.GIT, driven, Leg.Relay)) + } + + @Test + fun `on the relay everything stays on the relay connection`() { + for (cap in Capability.entries) { + assertEquals( + Leg.Primary, + LegRouting.route(cap, TransportKind.RELAY, LegRouting.RELAY_CAPABILITIES, RelayLeg.NOT_SET_UP), + "$cap" + ) + } + } + + @Test + fun `with no relay leg the verb is unavailable with the reason, and that reason is not 'turn it on' when it is on`() { + for (cap in appVerbs) { + val notSetUp = assertIs(LegRouting.route(cap, TransportKind.SSH, ssh, RelayLeg.NOT_SET_UP)) + assertTrue(notSetUp.reason.startsWith(cap.what), notSetUp.reason) + assertTrue(notSetUp.reason.contains("Turn on remote access"), notSetUp.reason) + + // Remote access is on (the phone holds a relay leg); the user chose SSH only. Sending + // them to turn remote access on would be the A26 error text again. + val sshOnly = assertIs(LegRouting.route(cap, TransportKind.SSH, ssh, RelayLeg.ROUTE_SSH_ONLY)) + assertFalse(sshOnly.reason.contains("remote access", ignoreCase = true), sshOnly.reason) + assertTrue(sshOnly.reason.contains("Only on my network (SSH)"), sshOnly.reason) + assertTrue(sshOnly.reason.contains("Settings → How to reach each computer"), sshOnly.reason) + } + } + + @Test + fun `not connected yet asks the relay leg when there is one`() { + // connectionFor connects first, so this only matters for the UI's state before the first + // listing: a control is not shown as impossible while the phone is still dialing. + assertEquals(Leg.Relay, LegRouting.route(Capability.REGISTER_NODE, null, null, RelayLeg.AVAILABLE)) + assertIs(LegRouting.route(Capability.REGISTER_NODE, null, null, RelayLeg.NOT_SET_UP)) + } + + @Test + fun `the relay leg's availability`() { + assertEquals(RelayLeg.AVAILABLE, LegRouting.relayLeg(relayConfigured = true, sshOnlyRoute = false)) + assertEquals(RelayLeg.ROUTE_SSH_ONLY, LegRouting.relayLeg(relayConfigured = true, sshOnlyRoute = true)) + assertEquals(RelayLeg.NOT_SET_UP, LegRouting.relayLeg(relayConfigured = false, sshOnlyRoute = false)) + assertEquals(RelayLeg.NOT_SET_UP, LegRouting.relayLeg(relayConfigured = false, sshOnlyRoute = true)) + } + + /** + * The review of A26: a token the phone holds outlives the computer's remote-access toggle, so the + * stored leg alone showed New session and the board enabled after remote access was turned off, + * and a tap waited out the relay's 20 s handshake before failing. What the computer advertises at + * the last listing over SSH (`~/.nodeterm/relay.json`) decides it. + */ + @Test + fun `what the computer advertises decides a stored relay leg`() { + assertEquals(RelayLeg.REMOTE_ACCESS_OFF, LegRouting.relayLeg(relayConfigured = true, sshOnlyRoute = false, relayAdvertised = false)) + assertEquals(RelayLeg.AVAILABLE, LegRouting.relayLeg(relayConfigured = true, sshOnlyRoute = false, relayAdvertised = true)) + // Unknown (on the relay itself, or before the first listing) never takes a leg away. + assertEquals(RelayLeg.AVAILABLE, LegRouting.relayLeg(relayConfigured = true, sshOnlyRoute = false, relayAdvertised = null)) + // No token, but remote access IS on: not "turn it on". + assertEquals(RelayLeg.NOT_PICKED_UP, LegRouting.relayLeg(relayConfigured = false, sshOnlyRoute = false, relayAdvertised = true)) + assertEquals(RelayLeg.NOT_SET_UP, LegRouting.relayLeg(relayConfigured = false, sshOnlyRoute = false, relayAdvertised = false)) + // The computer's side is said first; the phone's own route after it is fixed. + assertEquals(RelayLeg.REMOTE_ACCESS_OFF, LegRouting.relayLeg(relayConfigured = true, sshOnlyRoute = true, relayAdvertised = false)) + assertEquals(RelayLeg.ROUTE_SSH_ONLY, LegRouting.relayLeg(relayConfigured = true, sshOnlyRoute = true, relayAdvertised = true)) + // A computer added by its SSH address has no relay leg, whatever its file says. + for (advertised in listOf(true, false, null)) { + assertEquals(RelayLeg.ADDED_OVER_SSH, LegRouting.relayLeg(true, false, addedOverSsh = true, relayAdvertised = advertised)) + } + } + + @Test + fun `each missing relay leg names its own fix`() { + for (cap in appVerbs) { + val off = assertIs(LegRouting.route(cap, TransportKind.SSH, ssh, RelayLeg.REMOTE_ACCESS_OFF)).reason + assertTrue(off.startsWith(cap.what), off) + assertTrue(off.contains("remote access is off on the computer"), off) + assertTrue(off.contains("Settings → Phone"), off) + + val pickingUp = assertIs(LegRouting.route(cap, TransportKind.SSH, ssh, RelayLeg.NOT_PICKED_UP)).reason + assertTrue(pickingUp.startsWith(cap.what), pickingUp) + assertFalse(pickingUp.contains("Turn on remote access", ignoreCase = true), "remote access is on: $pickingUp") + assertTrue(pickingUp.contains("Refresh"), pickingUp) + + // It promised a pickup "the next time it connects", which a reused connection never did. + val notSetUp = assertIs(LegRouting.route(cap, TransportKind.SSH, ssh, RelayLeg.NOT_SET_UP)).reason + assertFalse(notSetUp.contains("next time it connects"), notSetUp) + assertTrue(notSetUp.contains("next refresh"), notSetUp) + } + } + + @Test + fun `a listing adopts the advertised relay on a refresh the user asked for, or when it newly appears`() { + val missing = listOf(RelayLeg.NOT_SET_UP, RelayLeg.NOT_PICKED_UP) + for (leg in missing) { + assertTrue(LegRouting.adoptAfterListing(leg, advertisedBefore = true, advertisedNow = true, userAsked = true), "Refresh / Try again, $leg") + assertTrue(LegRouting.adoptAfterListing(leg, advertisedBefore = false, advertisedNow = true, userAsked = false), "turned on while watched, $leg") + // The 8 s poll does not mint again and again while it keeps failing. + assertFalse(LegRouting.adoptAfterListing(leg, advertisedBefore = true, advertisedNow = true, userAsked = false), "$leg") + // The connect adopts on its own; the first listing on that connection does not ask twice. + assertFalse(LegRouting.adoptAfterListing(leg, advertisedBefore = null, advertisedNow = true, userAsked = false), "$leg") + // Nothing advertised: nothing to adopt. + for (now in listOf(false, null)) assertFalse(LegRouting.adoptAfterListing(leg, advertisedBefore = false, advertisedNow = now, userAsked = true)) + } + // A phone that holds a leg, or a computer that cannot have one, never adopts. + for (leg in RelayLeg.entries - missing.toSet()) { + assertFalse(LegRouting.adoptAfterListing(leg, advertisedBefore = false, advertisedNow = true, userAsked = true), "$leg") + } + } + + @Test + fun `reach answers every capability from the same rule`() { + val reach = LegRouting.reach(TransportKind.SSH, ssh, RelayLeg.AVAILABLE) + assertEquals(Capability.entries.toSet(), reach.keys) + for (cap in Capability.entries) assertEquals(LegRouting.route(cap, TransportKind.SSH, ssh, RelayLeg.AVAILABLE), reach[cap]) + } + + @Test + fun `the SSH refusal names the relay, not a setting that may already be on`() { + val msg = LegRouting.sshRefusal("Editing the board") + assertTrue(msg.startsWith("Editing the board")) + assertTrue(msg.contains("through the relay")) + assertFalse(msg.contains("turn on remote access")) + } + + /** + * The app asks this decision instead of the transport kind. What only a device can run (the + * Compose screens) is pinned in the source, like AppSourcePins does elsewhere. + */ + @Test + fun `the app's controls decide from the routing, and write through the leg it names`() { + val host = AppSourcePins.ui("HostScreen.kt") + // The FAB used to be gated on `kind == TransportKind.RELAY` and vanished on the LAN. + assertFalse(host.contains("kind == TransportKind.RELAY"), "HostScreen gates New session on the transport kind") + assertTrue(host.contains("session.route(Capability.REGISTER_NODE)")) + assertTrue(host.contains("enabled = false"), "an unavailable New session is shown disabled, not hidden") + + val board = AppSourcePins.ui("BoardTab.kt") + // Per project since A27: a project another desktop drives over SSH has its own answer. + assertTrue(board.contains("session.route(Capability.BOARD_WRITES, project)")) + assertTrue(board.contains("session.connectionFor(Capability.BOARD_WRITES, project = project)")) + assertFalse(board.contains("ensureConnected().setCardColumn"), "a board write must not go to the SSH leg") + assertFalse(board.contains("ensureConnected().editCardLabels"), "a label edit must not go to the SSH leg") + + val sessions = AppSourcePins.ui("SessionsTab.kt") + assertTrue(sessions.contains("session.route(Capability.NODE_ACTIONS, project)")) + assertFalse(sessions.contains("connectionFor(Capability.NODE_ACTIONS)"), "every node action names its project") + assertFalse(sessions.contains("capabilities?.nodeActions"), "node actions decided from the primary leg alone") + + // A session the phone starts is created and registered through the leg that can register it. + val term = AppSourcePins.ui("TerminalController.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(term, "private fun attach()"), + "PendingLaunches.peek(nodeId) != null -> session.connectionFor(Capability.REGISTER_NODE" + ) + + // The side relay goes through the approval gate with the caller's trigger (never a first + // handshake from the background), and connectionFor defaults to the user's tap. + val conn = AppSourcePins.app("conn/ConnectionManager.kt") + val viaRelay = AppSourcePins.blockAfter(conn.substring(conn.indexOf("suspend fun viaRelay(")), "return sideMutex.withLock") + assertTrue(viaRelay.contains("graph.relayGate.decide(hostId, trigger)")) + assertTrue(viaRelay.contains("requireApproved = requireApproved")) + assertTrue(Regex("""fun connectionFor\(\s*cap: Capability,\s*trigger: Trigger = Trigger\.USER""").containsMatchIn(conn)) + // …and decides with the project's answer, the one the screens show (A27). + val connectionFor = AppSourcePins.blockAfter(conn.substring(conn.indexOf("suspend fun connectionFor(")), "): HostConnection") + assertTrue(connectionFor.contains("route(cap, project)"), connectionFor) + } + + /** + * The review of A26: [LegRouting.route] is asked while composing (the host screen, the board, the + * session menu, Source control), on every listing. It used to decrypt the relay token there — a + * Keystore round trip on the main thread under the store's lock, the A47 pattern — and a keystore + * hiccup then read as "no relay leg", whose reason tells a user with remote access ON to turn it on. + */ + @Test + fun `the routing asks whether a relay token is stored, never for its value`() { + val conn = AppSourcePins.app("conn/ConnectionManager.kt") + val configured = conn.substring(conn.indexOf("private fun relayConfigured(")).let { it.substring(0, it.indexOf("\n\n")) } + assertTrue(configured.contains("graph.secure.hasRelayToken(host.id)"), configured) + assertFalse(configured.contains("secure.getString") || configured.contains("secure.getBytes"), configured) + val relayLeg = AppSourcePins.blockAfter(conn, "fun relayLeg(): LegRouting.RelayLeg") + assertTrue(relayLeg.contains("relayConfigured = relayConfigured(host)"), relayLeg) + assertFalse(relayLeg.contains("secure."), relayLeg) + val route = AppSourcePins.blockAfter(conn, "fun route(cap: Capability): LegRouting.Leg") + assertFalse(route.contains("secure."), route) + // The screens that ask it never read a secret themselves either. + for (file in listOf("HostScreen.kt", "BoardTab.kt", "SessionsTab.kt", "SourceControlScreen.kt")) { + val src = AppSourcePins.ui(file) + assertFalse(src.contains("secure.getString") || src.contains("secure.getBytes"), "$file decrypts a secret") + } + } + + @Test + fun `the app feeds the computer's advertisement into the routing, and adopts on a listing`() { + val conn = AppSourcePins.app("conn/ConnectionManager.kt") + val relayLeg = AppSourcePins.blockAfter(conn, "fun relayLeg(): LegRouting.RelayLeg") + assertTrue(relayLeg.contains("relayAdvertised = (conn as? SshHostConnection)?.relayAdvertised"), relayLeg) + val refresh = AppSourcePins.blockAfter(conn, "suspend fun refreshNow(trigger: Trigger = Trigger.AUTO)") + AppSourcePins.assertInOrder( + refresh, + "val advertisedBefore = ssh?.relayAdvertised", + "c.listProjects()", + "LegRouting.adoptAfterListing(relayLeg(), advertisedBefore, ssh.relayAdvertised, userAsked = trigger == Trigger.USER)", + "adoptInBackground(ssh)" + ) + // The screens re-ask when a late adoption stores the leg, which no listing announces. + for (file in listOf("HostScreen.kt", "BoardTab.kt")) { + val src = AppSourcePins.ui(file) + assertTrue(src.contains("val secretsRevision by graph.secure.revision.collectAsState()"), file) + assertTrue(src.contains("val hostRecords by graph.hosts.hosts.collectAsState()"), file) + } + assertTrue(AppSourcePins.ui("HostScreen.kt").contains("remember(state, snapshot, secretsRevision, hostRecords) { session.route(Capability.REGISTER_NODE) }")) + assertTrue(AppSourcePins.ui("BoardTab.kt").contains("remember(snapshot, project, secretsRevision, hostRecords) { session.route(Capability.BOARD_WRITES, project) }")) + } + + /** + * The review of A26: the disabled New session carried its reason in a floating box of up to + * 300 dp beside it — five to seven lines over a list that reserves 96 dp for a FAB — covering the + * last sessions. The reason is the list's first row; the button stays as small as a FAB. + */ + @Test + fun `a disabled New session says why at the top of the list, not over its last rows`() { + val host = AppSourcePins.ui("HostScreen.kt") + val fab = AppSourcePins.blockAfter(host, "floatingActionButton = {") + // No text of the reason beside the button (the old box was `Text(blocked.reason, …widthIn(max = 300.dp)…)`). + assertFalse(fab.contains(".reason") || fab.contains("Text(newBlocked") || fab.contains("widthIn"), fab) + assertTrue(fab.contains("enabled = false"), fab) + assertTrue(host.contains("SessionsTab(nav, hostId, session, snapshot, newSessionNote = newBlocked?.takeIf { offersNew })")) + val sessions = AppSourcePins.ui("SessionsTab.kt") + AppSourcePins.assertInOrder( + sessions, + "newSessionNote?.let { NewSessionNote(it) }", // also when no project has a session yet + "LazyColumn(Modifier.weight(1f).fillMaxWidth(), contentPadding = PaddingValues(bottom = 96.dp)) {", + "if (newSessionNote != null) item(key = \"new-session-note\") { NewSessionNote(newSessionNote) }", + "for (project in projects) {" + ) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LegacyRelayPairingInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LegacyRelayPairingInteropTest.kt new file mode 100644 index 0000000000..92bc3587cc --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LegacyRelayPairingInteropTest.kt @@ -0,0 +1,245 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.host.RelayApprovalRequiredException +import dev.nodeterm.protocol.host.RelayApprovalRefusedException +import dev.nodeterm.protocol.host.RelayConnectStatus +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.RelayConnector +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.pairing.PairingClient +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.pairing.RelayPairingProof +import dev.nodeterm.protocol.pairing.SshIdentity +import dev.nodeterm.protocol.relay.OkHttpRelayTransport +import dev.nodeterm.protocol.relay.RelaySocket +import dev.nodeterm.protocol.relay.RelaySocketListener +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import net.i2p.crypto.eddsa.EdDSAEngine +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** Real producer/client proof across sealed pairing, encrypted approved relay, attributed public + * SSH key and guarded host publication. The public pin adapter and SAS decision are fixture seams. */ +class LegacyRelayPairingInteropTest { + private val homes = ArrayList() + private val harnesses = ArrayList() + + @AfterTest fun clean() { + harnesses.forEach { it.close() } + homes.forEach { it.deleteRecursively() } + } + + private fun start(extra: Map = emptyMap()): InteropHarness { + val home = Files.createTempDirectory("nt-legacy-interop-").toFile().also { homes += it } + return InteropHarness.start("legacy-pairing", InteropHarness.scratchHomeEnv(home) + + mapOf("FIXTURE_USERDATA" to home.path) + extra).also { harnesses += it } + } + + private fun JsonObject.str(key: String) = this[key]!!.jsonPrimitive.content + + private suspend fun pair(h: InteropHarness, identity: SshIdentity, box: BoxKeyPair? = null, again: Boolean = false): PairedHost { + val raw = if (again) { + h.command("start-pair") + h.awaitEvent("legacy-pair-ready").str("payload") + } else h.ready.str("payload") + // The fake OS advertises a synthetic LAN address. Only this fixture's HTTP dial is + // redirected to loopback; the real sealed exchange and retained host identity remain. + val payload = assertNotNull(PairingPayload.parse(raw)).copy(host = "127.0.0.1") + val result = PairingClient().pair(payload, identity.authorizedKeysLine(), "Fixture phone", "fixture-phone", + boxPublicKeyB64 = box?.publicKeyB64) + assertEquals("true", h.awaitEvent("legacy-paired").str("ok")) + assertEquals(box != null, result.relayPinned) + return assertNotNull(PairedHost.fromJson(PairedHost.from(payload, result).toJson())) + } + + private fun context(host: PairedHost, box: BoxKeyPair, seed: () -> ByteArray?) = + RelayPairingProof.Context(host, assertNotNull(host.hostKeyB64), box.publicKeyB64, seed) {} + + private suspend fun connect(h: InteropHarness, box: BoxKeyPair, proof: RelayPairingProof.Context? = null, + room: String = "subject", requireApproved: Boolean = false, + statuses: MutableList? = null) = RelayConnector.connect( + relayUrl = h.ready.str("relayUrl"), token = "client-$room", deviceKeys = box, + hostPublicKeyB64 = h.ready.str("hostPublicKeyB64"), legacyPairing = proof, + approvalPollMs = 50, approvalTimeoutMs = 2_000, requireApproved = requireApproved, + onStatus = { statuses?.add(it) }) + + private fun state(h: InteropHarness): JsonObject { + h.command("state") + return h.awaitEvent("legacy-state") + } + + private fun association(state: JsonObject, id: String): String? = state["devices"]!!.jsonArray + .map { it.jsonObject }.single { it.str("id") == id }["relayBoxKey"]?.jsonPrimitive?.content + + private suspend fun provenPairing(sharedKey: Boolean) { + val h = start() + val identity = SshIdentity.generate() + val subject = BoxKeyPair.generate() + val other = if (sharedKey) subject else BoxKeyPair.generate() + val legacy = pair(h, identity) + val sibling = pair(h, identity, other, again = true) + val before = state(h) + assertEquals(null, association(before, legacy.id)) + assertEquals(other.publicKeyB64, association(before, sibling.id)) + val unaffected = connect(h, other, room = "other") + try { + var seedReads = 0 + val connected = connect(h, subject, context(legacy, subject) { seedReads++; identity.seed.copyOf() }) + try { + assertEquals(1, seedReads, "only the retained identity is used") + assertTrue(connected.first.projects.isNotEmpty()) + val migrated = state(h) + assertEquals(subject.publicKeyB64, association(migrated, legacy.id)) + assertEquals(other.publicKeyB64, association(migrated, sibling.id)) + assertTrue(migrated["killed"]!!.jsonArray.isEmpty(), "association never revokes a key") + assertEquals(RelayPairingProof.Outcome.ASSOCIATED, + connected.connection.proveLegacyPairing(context(legacy, subject) { identity.seed.copyOf() })) + h.command("revoke:${legacy.id}") + val revoked = h.awaitEvent("legacy-revoked")["result"]!!.jsonObject + assertEquals(if (sharedKey) "retained" else "ok", revoked.str("relay")) + val after = h.awaitEvent("legacy-state") + assertEquals(listOf(sibling.id), after["devices"]!!.jsonArray.map { it.jsonObject.str("id") }) + assertEquals(listOf(other.publicKeyB64), after["pins"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertEquals(if (sharedKey) emptyList() else listOf(subject.publicKeyB64), + after["killed"]!!.jsonArray.map { it.jsonPrimitive.content }) + val live = after["live"]!!.jsonArray.map { it.jsonObject } + assertEquals("true", live.single { it.str("room") == "other" }.str("approved")) + assertEquals(sharedKey.toString(), live.single { it.str("room") == "subject" }.str("approved")) + assertTrue(unaffected.connection.listProjects().projects.isNotEmpty(), "another approved session survives") + if (sharedKey) assertTrue(connected.connection.listProjects().projects.isNotEmpty()) + } finally { connected.connection.close() } + } finally { unaffected.connection.close() } + } + + @Test fun `retained SSH proof repairs a legacy entry and revoke cuts only its key (A118)`() = runBlocking { + provenPairing(sharedKey = false) + } + + @Test fun `a proven key authorized by another pairing remains retained on revoke (A118)`() = runBlocking { + provenPairing(sharedKey = true) + } + + @Test fun `a different retained SSH identity cannot associate but browsing continues (A118)`() = runBlocking { + val h = start() + val host = pair(h, SshIdentity.generate()) + val box = BoxKeyPair.generate() + val wrong = SshIdentity.generate() + val connected = connect(h, box, context(host, box) { wrong.seed.copyOf() }) + try { + assertTrue(connected.first.projects.isNotEmpty()) + assertEquals(null, association(state(h), host.id)) + } finally { connected.connection.close() } + } + + @Test fun `an older host refuses migration without preventing approved browsing (A118)`() = runBlocking { + val h = start(mapOf("FIXTURE_LEGACY_UNSUPPORTED" to "1")) + val identity = SshIdentity.generate() + val host = pair(h, identity) + val box = BoxKeyPair.generate() + val connected = connect(h, box, context(host, box) { identity.seed.copyOf() }) + try { + assertTrue(connected.first.projects.isNotEmpty()) + assertEquals(null, association(state(h), host.id)) + } finally { connected.connection.close() } + } + + @Test fun `an unapproved encrypted session never reads the retained identity (A118)`() = runBlocking { + val h = start(mapOf("FIXTURE_LEGACY_APPROVE" to "0")) + val identity = SshIdentity.generate() + val host = pair(h, identity) + val box = BoxKeyPair.generate() + var reads = 0 + assertFailsWith { + connect(h, box, context(host, box) { reads++; identity.seed.copyOf() }, requireApproved = true) + } + assertEquals(0, reads) + val after = state(h) + assertEquals(null, association(after, host.id)) + assertTrue(after["pins"]!!.jsonArray.isEmpty()) + assertFalse(after["live"]!!.jsonArray.any { it.jsonObject.str("approved") == "true" }) + } + + @Test fun `encrypted proof rejects a wrong signature and consumes it only on its owning session (A118)`() = runBlocking { + val h = start() + val identity = SshIdentity.generate() + val host = pair(h, identity) + val box = BoxKeyPair.generate() + suspend fun socket(room: String): RelaySocket { + val ready = CompletableDeferred() + val socket = RelaySocket(h.ready.str("relayUrl"), "client-$room", box, h.ready.str("hostPublicKeyB64"), + OkHttpRelayTransport.factory(), object : RelaySocketListener { + override fun onReady(sas: String) { ready.complete(Unit) } + override fun onClosed(reason: String?) { ready.completeExceptionally(AssertionError("fixture connection closed")) } + }) + try { withTimeout(3_000) { ready.await() }; socket.call("projects.list"); return socket } + catch (e: Throwable) { socket.close(); throw e } + } + val subject = socket("subject") + val other = socket("other") + fun signature(challenge: RelayPairingProof.Challenge, signer: SshIdentity): String { + val engine = EdDSAEngine(MessageDigest.getInstance("SHA-512")) + engine.initSign(signer.keyPair.private) + engine.update(challenge.signingBytes()) + return B64.encode(engine.sign()) + } + suspend fun challenge() = assertNotNull(RelayPairingProof.parseChallenge(subject.call( + RelayPairingProof.CHALLENGE_METHOD, buildJsonObject { put("pairingId", host.id) }), + context(host, box) { identity.seed.copyOf() }, identity.publicKeyBytes, System.currentTimeMillis())) + suspend fun submit(socket: RelaySocket, challenge: RelayPairingProof.Challenge, signer: SshIdentity) = + assertNotNull(socket.call(RelayPairingProof.PROOF_METHOD, buildJsonObject { + put("challengeId", challenge.challengeId); put("signatureB64", signature(challenge, signer)) + })).jsonObject.str("status") + try { + val held = challenge() + assertEquals("unprovable", submit(other, held, identity), "a second session cannot consume another session's proof") + assertEquals("unprovable", submit(subject, held, SshIdentity.generate()), "a validly encoded foreign signature is refused") + assertEquals(null, association(state(h), host.id)) + assertEquals("unprovable", submit(subject, held, identity), "a failed signature consumes the challenge") + val fresh = challenge() + assertEquals("associated", submit(subject, fresh, identity)) + assertEquals(box.publicKeyB64, association(state(h), host.id)) + } finally { subject.close(); other.close() } + } + + @Test fun `a socket closed during optional migration is never handed off as connected (A118)`() = runBlocking { + val h = start(mapOf("FIXTURE_LEGACY_CLOSE_DURING_PROOF" to "1", "FIXTURE_LEGACY_APPROVE_AFTER_MS" to "500")) + val identity = SshIdentity.generate() + val host = pair(h, identity) + val box = BoxKeyPair.generate() + var returned: RelayConnector.Connected? = null + val statuses = ArrayList() + try { + val error = assertFailsWith { + // Socket close resumes pending RPCs before its listener callback. Unconfined + // dispatch exercises that ordering instead of relying on callback timing. + withContext(Dispatchers.Unconfined) { + returned = connect(h, box, context(host, box) { identity.seed.copyOf() }, statuses = statuses) + } + } + assertTrue(statuses.any { it is RelayConnectStatus.AwaitingApproval }, "the human approval wait was actually displayed") + assertFalse(error is RelayApprovalRefusedException, "a network close after proved approval is not Deny") + h.awaitEvent("legacy-mid-proof-close") + assertEquals(null, association(state(h), host.id)) + } finally { returned?.connection?.close() } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ListingFailureTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ListingFailureTest.kt new file mode 100644 index 0000000000..370cfc43b1 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ListingFailureTest.kt @@ -0,0 +1,101 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.ListingFailure +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.ssh.HostBrowse +import dev.nodeterm.protocol.ssh.NothingFoundException +import dev.nodeterm.protocol.ssh.PhoneTerminals +import dev.nodeterm.protocol.ssh.SshScripts +import kotlinx.coroutines.CancellationException +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class ListingFailureTest { + private val id = "phone-12345678-1234-1234-1234-123456789abc" + + private fun phoneListing(): ProjectsSnapshot { + val cwd = "/srv/phone fixture" + val creation = PhoneTerminals.fingerprint(id, cwd) + val record = listOf(TmuxNames.sessionName(id), id, creation, cwd, creation, cwd).joinToString("\t") + val raw = "${SshScripts.META_START}\nud=\n${SshScripts.META_END}\n" + + "${SshScripts.PHONE_MARK}\n$record\n${SshScripts.END_MARK}\n" + return HostBrowse.assemble(ProjectsSnapshot.EMPTY, HostBrowse.split(raw), 123) + } + + @Test fun `ending the last phone shell replaces its cached row with the authoritative empty answer`() { + val previous = phoneListing() + assertTrue(previous.isLive(id)) + assertTrue(previous.findNode(id) != null) + val current = ListingFailure.snapshot(previous, NothingFoundException(), now = 456) + assertEquals(456L, current.fetchedAt, "an authoritative empty answer completed the listing") + assertTrue(current.projects.isEmpty()) + assertTrue(current.liveSessions.isEmpty()) + assertTrue(current.sockets.isEmpty()) + assertNull(current.findNode(id)) + assertNull(current.status) + assertTrue(previous.isLive(id), "replacing the current listing must not mutate another cached snapshot") + } + + @Test fun `the initial empty sentinel stays unlisted until an authoritative answer completes`() { + val initial = ProjectsSnapshot.EMPTY + assertEquals(0L, initial.fetchedAt) + for (error in listOf(HostException("The command was refused."), IOException("The reply was lost."))) { + assertSame(initial, ListingFailure.snapshot(initial, error, now = 456)) + } + val listed = ListingFailure.snapshot(initial, NothingFoundException(), now = 789) + assertEquals(789L, listed.fetchedAt) + assertTrue(listed.projects.isEmpty()) + assertTrue(listed.liveSessions.isEmpty()) + assertEquals(0L, initial.fetchedAt, "the shared initial sentinel must not be mutated") + assertTrue(ListingFailure.snapshot(initial, NothingFoundException()).fetchedAt > 0, + "native refresh uses the default clock and must leave the initial loading state") + } + + @Test fun `a host refusal or lost transport retains the complete last phone listing`() { + val previous = phoneListing() + for (error in listOf(HostException("The command was refused."), IOException("The reply was lost."))) { + val current = ListingFailure.snapshot(previous, error) + assertSame(previous, current, "a failed command does not prove the shell ended") + assertTrue(current.isLive(id)) + assertTrue(current.findNode(id) != null) + assertTrue(current.socketOf(id) == TmuxNames.PHONE_SOCKET) + } + } + + @Test fun `cancellation propagates before a cached listing can be replaced`() { + val previous = phoneListing() + var current = previous + val cancelled = CancellationException("The host screen stopped.") + val thrown = assertFailsWith { + current = ListingFailure.snapshot(current, cancelled) + } + assertSame(cancelled, thrown) + assertSame(previous, current) + assertTrue(current.isLive(id)) + } + + @Test fun `the host applies the policy after cancellation while preserving its error and connection rules`() { + val source = AppSourcePins.app("conn/ConnectionManager.kt") + val refresh = AppSourcePins.blockAfter(source, "suspend fun refreshNow(") + AppSourcePins.assertInOrder(refresh, + "} catch (e: kotlinx.coroutines.CancellationException) {", + "throw e", + "} catch (e: Exception) {", + "_snapshot.value = ListingFailure.snapshot(_snapshot.value, e)", + "_lastError.value = (e as? NothingFoundException)?.said(relayLeg()) ?: e.message ?: e.javaClass.simpleName", + "if (e !is HostException) disconnect()", + "return") + val failure = refresh.substringAfter("} catch (e: Exception) {") + .substringBefore("// Outside the try:") + assertFalse(failure.contains("_state.value"), "an authoritative empty answer keeps SSH connected and New terminal available") + assertFalse(failure.contains("conn ="), "ordinary host answers must not close the current SSH connection") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt new file mode 100644 index 0000000000..3a8eed68c0 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/LiveNotificationsTest.kt @@ -0,0 +1,428 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.OnScreen +import dev.nodeterm.protocol.model.OnScreenTracker +import dev.nodeterm.protocol.model.SeenLog +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * A73: notifications were documented as live every 8 s while a computer is open, but only the + * 15-minute background check ever posted one; the in-app refresh only updated the listing. Now every + * fresh listing of a computer runs the one announce path, so notifications are live for the computer + * whose screen is open, minus what the user is looking at: every event while that computer's Inbox + * tab is on screen, and what the pane of a session attached in a terminal shows. Those are recorded as + * seen, so no later check announces them. Other computers are not polled, and the copy now says so. + * The review of A73 narrowed "a terminal is open" to what its pane shows: not a held hook-reply + * approval (its prompt is not painted while held), nothing while an overlay covers the pane, and + * nothing decided yet while the terminal is still connecting. + * + * The decision ([OnScreen], [SeenLog.claimLive]) and the screen bookkeeping ([OnScreenTracker]) are + * pure and tested here. The wiring (the listing announces, the screens register while started, the + * copy) cannot run on a JVM, so it is pinned in the app's source ([AppSourcePins]); whether a + * notification appears on a phone is a device check. + */ +class LiveNotificationsTest { + private class MemStorage : SeenLog.Storage { + var raw: String? = null + var reads = 0 + var writes = 0 + override fun read(): String? = raw.also { reads++ } + override fun readV2(): String? = null + override fun readLegacy(): Set? = null + override fun write(encoded: String) { + raw = encoded + writes++ + } + } + + private class Clock(var now: Long) : () -> Long { + override fun invoke(): Long = now + } + + private val hour = 3_600_000L + private val t0 = 1_800_000_000_000L + + /** The computer whose listings these are (the log is kept per computer; see SeenLogTest). */ + private val computer = "host-a" + + private fun ev( + id: String, + nodeId: String = "n1", + ts: Long = t0, + kind: InboxKind = InboxKind.APPROVAL, + resolved: Boolean = false, + pendingId: String? = null + ) = InboxEvent( + id = id, ts = ts, nodeId = nodeId, agentId = "claude", sessionId = null, kind = kind, title = "t", + detail = null, interrupted = false, resolved = resolved, options = emptyList(), multiSelect = false, pendingId = pendingId + ) + + /** A held hook-reply approval: it carries its ticket, and its prompt is not painted while held. */ + private fun held(id: String, nodeId: String = "n1", ts: Long = t0) = ev(id, nodeId, ts, pendingId = "$nodeId-$ts-1") + + private fun ids(events: List) = events.map { it.id } + + // --- What is on screen --------------------------------------------------------------------- + + @Test + fun `with nothing on screen the whole feed may be announced`() { + val feed = listOf(ev("a", "n1"), ev("b", "n2", kind = InboxKind.DONE)) + val split = OnScreen.NOTHING.split(feed) + assertEquals(emptyList(), split.shown) + assertEquals(listOf("a", "b"), ids(split.offScreen)) + } + + @Test + fun `while the Inbox tab shows, every event of that computer is on screen`() { + // Questions and approvals are its cards; a finish is in its archive. None is announced. + val feed = listOf(ev("a", "n1"), ev("q", "n2", kind = InboxKind.QUESTION), ev("d", "n3", kind = InboxKind.DONE)) + val split = OnScreen(inbox = true).split(feed) + assertEquals(listOf("a", "q", "d"), ids(split.shown)) + assertEquals(emptyList(), split.offScreen) + } + + @Test + fun `a session open in a terminal hides only its own events, in feed order`() { + val feed = listOf(ev("a", "open"), ev("b", "other"), ev("c", "open", kind = InboxKind.DONE), ev("d", "other")) + val split = OnScreen(nodes = setOf("open")).split(feed) + assertEquals(listOf("a", "c"), ids(split.shown)) + assertEquals(listOf("b", "d"), ids(split.offScreen)) + assertTrue(OnScreen(nodes = setOf("open")).shows(ev("x", "open"))) + assertFalse(OnScreen(nodes = setOf("open")).shows(ev("x", "other"))) + } + + @Test + fun `a held approval is not in its session's pane, so an open terminal does not hide it`() { + // docs/hook-reply-approvals.md: Claude applies the hook's decision before it paints the prompt, + // so while the hook holds the request the pane shows no prompt (a subagent's concurrent + // approval rides the parent node, whose pane shows the parent's question). + assertFalse(OnScreen.inPane(held("h"))) + assertTrue(OnScreen.inPane(ev("keyed")), "an approval with no ticket is a prompt in the pane") + assertTrue(OnScreen.inPane(ev("q", kind = InboxKind.QUESTION))) + assertTrue(OnScreen.inPane(ev("d", kind = InboxKind.DONE))) + val onScreen = OnScreen(nodes = setOf("open")) + val feed = listOf(held("h", "open"), ev("keyed", "open"), ev("q", "open", kind = InboxKind.QUESTION), ev("d", "open", kind = InboxKind.DONE)) + val split = onScreen.split(feed) + assertEquals(listOf("keyed", "q", "d"), ids(split.shown)) + assertEquals(listOf("h"), ids(split.offScreen)) + // The Inbox tab lists it as a card with its buttons: there it IS on screen. + assertTrue(OnScreen(inbox = true).shows(held("h", "open"))) + } + + @Test + fun `a held approval of the open session is announced, and not recorded as seen until it is`() { + val clock = Clock(t0) + val log = SeenLog(MemStorage(), clock) + val onScreen = OnScreen(nodes = setOf("open")) + val feed = listOf(held("h", "open"), ev("keyed", "open")) + // Notifications off: the prompt in the pane is recorded, the held approval is not spent. + assertEquals(emptyList(), log.claimLive(computer, feed, onScreen, notify = false)) + assertTrue(log.isSeen(computer, ev("keyed"))) + assertFalse(log.isSeen(computer, ev("h")), "a held approval nobody could see was recorded as seen") + // Notifications on: announced once, whether the terminal is still open or the user left it. + clock.now += 8_000 + assertEquals(listOf("h"), ids(log.claimLive(computer, feed, onScreen, notify = true))) + clock.now += 8_000 + assertEquals(emptyList(), log.claimLive(computer, feed, onScreen, notify = true)) + assertEquals(emptyList(), log.claimAnnounceable(computer, feed)) + } + + @Test + fun `a terminal still connecting leaves its session's events for a later listing`() { + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + val opening = OnScreen(opening = setOf("open")) + val feed = listOf(ev("q", "open", kind = InboxKind.QUESTION), held("h", "open"), ev("other", "n2")) + val split = opening.split(feed) + assertEquals(listOf("q"), ids(split.waiting)) + assertEquals(listOf("h", "other"), ids(split.offScreen), "a held approval is not in the pane about to show") + assertEquals(emptyList(), split.shown) + assertEquals(listOf("h", "other"), ids(log.claimLive(computer, feed, opening, notify = true))) + assertFalse(log.isSeen(computer, ev("q")), "a pane that never showed was recorded as seen") + // The attach failed and an overlay covers the pane: the next listing announces it. + clock.now += 8_000 + assertEquals(listOf("q"), ids(log.claimLive(computer, feed, OnScreen.NOTHING, notify = true))) + } + + @Test + fun `once the terminal attaches, its pane is recorded from the latest listing, and leaving announces nothing`() { + val clock = Clock(t0) + val log = SeenLog(MemStorage(), clock) + val feed = listOf(ev("d", "open", kind = InboxKind.DONE), held("h", "open")) + assertEquals(listOf("h"), ids(log.claimLive(computer, feed, OnScreen(opening = setOf("open")), notify = true))) + // HostSession.notePaneShown, on the attach: records what the pane shows, announces nothing. + assertEquals(emptyList(), log.claimLive(computer, feed, OnScreen(nodes = setOf("open")), notify = false)) + assertTrue(log.isSeen(computer, ev("d"))) + // The user looked and left before the next refresh: nothing about it afterwards. + clock.now += 3_000 + assertEquals(emptyList(), log.claimLive(computer, feed, OnScreen.NOTHING, notify = true)) + assertEquals(emptyList(), log.claimAnnounceable(computer, feed)) + } + + // --- One check of a fresh listing ----------------------------------------------------------- + + @Test + fun `nothing on screen claims exactly what the background check claims`() { + val feed = listOf( + ev("a", ts = t0 - hour), + ev("old", ts = t0 - SeenLog.ANNOUNCE_WINDOW_MS), + ev("resolved", resolved = true), + ev("b", "n2") + ) + val live = SeenLog(MemStorage(), Clock(t0)) + val background = SeenLog(MemStorage(), Clock(t0)) + assertEquals(ids(background.claimAnnounceable(computer, feed)), ids(live.claimLive(computer, feed, OnScreen.NOTHING, notify = true))) + assertEquals(listOf("a", "b"), ids(SeenLog(MemStorage(), Clock(t0)).claimLive(computer, feed, OnScreen.NOTHING, notify = true))) + } + + @Test + fun `an event shown on the Inbox tab is never announced, by this check or a later one`() { + val clock = Clock(t0) + val log = SeenLog(MemStorage(), clock) + val feed = listOf(ev("a", "n1"), ev("d", "n2", kind = InboxKind.DONE)) + assertEquals(emptyList(), log.claimLive(computer, feed, OnScreen(inbox = true), notify = true)) + // The user left the tab (or the app): the next refresh and the background check stay quiet. + clock.now += 60_000 + assertEquals(emptyList(), log.claimLive(computer, feed, OnScreen.NOTHING, notify = true)) + assertEquals(emptyList(), log.claimAnnounceable(computer, feed)) + // Something new after that is announced, once. + val next = feed + ev("b", "n1", ts = clock.now) + assertEquals(listOf("b"), ids(log.claimLive(computer, next, OnScreen.NOTHING, notify = true))) + assertEquals(emptyList(), log.claimLive(computer, next, OnScreen.NOTHING, notify = true)) + } + + @Test + fun `with a terminal open, its session is recorded and another session is announced once`() { + val clock = Clock(t0) + val log = SeenLog(MemStorage(), clock) + val onScreen = OnScreen(nodes = setOf("open")) + val feed = listOf(ev("mine", "open"), ev("theirs", "other")) + assertEquals(listOf("theirs"), ids(log.claimLive(computer, feed, onScreen, notify = true))) + clock.now += 8_000 + assertEquals(emptyList(), log.claimLive(computer, feed, onScreen, notify = true)) + // Back from the terminal: what it showed was seen there. + assertEquals(emptyList(), log.claimAnnounceable(computer, feed)) + assertTrue(log.isSeen(computer, ev("mine"))) + } + + @Test + fun `with notifications off nothing is claimed, but what is on screen is still recorded`() { + val log = SeenLog(MemStorage(), Clock(t0)) + val feed = listOf(ev("shown", "open"), ev("offscreen", "other")) + assertEquals(emptyList(), log.claimLive(computer, feed, OnScreen(nodes = setOf("open")), notify = false)) + assertTrue(log.isSeen(computer, ev("shown"))) + assertFalse(log.isSeen(computer, ev("offscreen")), "an event nobody saw was spent while notifications were off") + // Turned on later: only what the user never looked at is announced. + assertEquals(listOf("offscreen"), ids(log.claimLive(computer, feed, OnScreen.NOTHING, notify = true))) + } + + @Test + fun `the 8 s check writes only what is new and could still be announced`() { + val storage = MemStorage() + val clock = Clock(t0) + val log = SeenLog(storage, clock) + val feed = listOf( + ev("a", "n1"), + ev("resolved", "n1", resolved = true), + ev("old", "n1", ts = t0 - SeenLog.ANNOUNCE_WINDOW_MS) + ) + assertEquals(emptyList(), log.claimLive(computer, feed, OnScreen(inbox = true), notify = true)) + assertEquals(1, storage.writes) + assertEquals(setOf("a"), SeenLog.decode(storage.raw!!, clock.now).ids(computer)) + // The same tab, ten refreshes later: nothing new, nothing written, the anchor unmoved. + repeat(10) { + clock.now += 8_000 + log.claimLive(computer, feed, OnScreen(inbox = true), notify = true) + } + assertEquals(1, storage.writes) + assertEquals(t0, SeenLog.decode(storage.raw!!, clock.now).anchors(computer)?.get("a")) + // A feed with nothing announceable in it is not even read back. + val reads = storage.reads + assertEquals(emptyList(), log.claimLive(computer, listOf(feed[1], feed[2]), OnScreen.NOTHING, notify = true)) + assertEquals(reads, storage.reads) + assertEquals(1, storage.writes) + } + + // --- Which screens are showing ---------------------------------------------------------------- + + @Test + fun `screens are counted, so an overlapping start and stop keeps what is still showing`() { + val tracker = OnScreenTracker() + assertSame(OnScreen.NOTHING, tracker.now()) + val first = tracker.showInbox() + val second = tracker.showInbox() // the next screen started before the last one stopped + first.close() + assertEquals(OnScreen(inbox = true), tracker.now()) + second.close() + assertEquals(OnScreen.NOTHING, tracker.now()) + } + + @Test + fun `a terminal's session is on screen until its last handle closes, and a handle closes once`() { + val tracker = OnScreenTracker() + val a = tracker.showNode("n1") + val b = tracker.showNode("n1") + val c = tracker.showNode("n2") + assertEquals(OnScreen(nodes = setOf("n1", "n2")), tracker.now()) + a.close() + a.close() // twice counts once: n1 is still open through b + assertEquals(OnScreen(nodes = setOf("n1", "n2")), tracker.now()) + b.close() + assertEquals(OnScreen(nodes = setOf("n2")), tracker.now()) + c.close() + assertEquals(OnScreen.NOTHING, tracker.now()) + } + + @Test + fun `what is reported is a copy, not a live view`() { + val tracker = OnScreenTracker() + val h = tracker.showNode("n1") + val seen = tracker.now() + h.close() + assertEquals(setOf("n1"), seen.nodes) + } + + @Test + fun `a terminal counts only while its pane shows, and the tracker asks it at every listing`() { + val tracker = OnScreenTracker() + var pane = OnScreen.Pane.OPENING + val h = tracker.showNode("n1") { pane } + assertEquals(OnScreen(opening = setOf("n1")), tracker.now()) + pane = OnScreen.Pane.SHOWN + assertEquals(OnScreen(nodes = setOf("n1")), tracker.now()) + pane = OnScreen.Pane.HIDDEN // an overlay over the pane: the session ended, an offer, a lost view + assertSame(OnScreen.NOTHING, tracker.now()) + // Two terminals of one session: the one that shows it wins over the one still connecting. + val other = tracker.showNode("n1") { OnScreen.Pane.SHOWN } + pane = OnScreen.Pane.OPENING + assertEquals(OnScreen(nodes = setOf("n1")), tracker.now()) + other.close() + assertEquals(OnScreen(opening = setOf("n1")), tracker.now()) + h.close() + assertSame(OnScreen.NOTHING, tracker.now()) + } + + // --- The wiring, pinned in the app's source ----------------------------------------------------- + + private val connections get() = AppSourcePins.app("conn/ConnectionManager.kt") + private val notifier get() = AppSourcePins.app("notify/InboxNotifier.kt") + + @Test + fun `every listing that arrives announces, outside the listing's own failure handling`() { + val refresh = AppSourcePins.blockAfter(connections, "suspend fun refreshNow(") + // A notification the system refused must not read as a transport failure (that disconnects). + AppSourcePins.assertInOrder( + refresh, + "val listed = try {", + "c.listProjects().also {", + "_snapshot.value = it", + "} catch (e: Exception) {", + "if (e !is HostException) disconnect()", + "return", + "runCatching { lifetime.whenCurrent(operation) { graph.announce(hostId, listed, onScreen.now(), quiet = reachableQuietly()) } }" + ) + // The 8 s poll re-lists through it. + assertTrue(connections.contains("private val foreground = ForegroundRefresh(")) + assertTrue(connections.contains("refresh = { trigger -> refreshNow(trigger) }")) + assertTrue(connections.contains("pause = { delay(POLL_MS) }")) + assertTrue(AppSourcePins.blockAfter(connections, "fun startWatching(").contains("foreground.start(initialTrigger)")) + val graph = AppSourcePins.app("NodetermApp.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(graph, "fun announce(hostId: String, snapshot: ProjectsSnapshot, onScreen: OnScreen, quiet: Boolean)"), + "InboxNotifier.announce(appContext, host, snapshot, onScreen, quiet)" + ) + } + + @Test + fun `a change pushed by a computer whose screen is closed is not re-listed live`() { + // The connection outlives the screen (the review of A73): a computer the user left kept + // announcing live through its pushes until the connection dropped or the worker ran, while the + // copy promises live notifications only for the computer on screen. Like the reconnect. + val adopt = AppSourcePins.blockAfter(connections, "private fun adopt(c: HostConnection, lease: HostLifetime.Lease)") + AppSourcePins.assertInOrder(adopt, "if (reconnect && isWatched) foreground.changed(stillCurrent = { lifetime.isCurrent(lease) && conn == null })", "delay(1_500)") + assertTrue(adopt.contains("c.setOnChanged { if (lifetime.isCurrent(lease) && conn === c && isWatched) foreground.changed(stillCurrent = { lifetime.isCurrent(lease) && conn === c }) }"), adopt) + assertEquals(1, Regex("""\.setOnChanged\b""").findAll(connections).count(), "another connection re-lists on its pushes") + } + + @Test + fun `announce decides with what is on screen, and gates only the claim on the switch and permission`() { + val announce = AppSourcePins.blockAfter(notifier, "fun announce(") + AppSourcePins.assertInOrder( + announce, + "val notify = graph.hosts.notificationsEnabled && canPost(context) && permitted", + "graph.hosts.claimLive(host.id, snapshot.status?.inbox?.events.orEmpty(), onScreen, notify)", + "nm.notify(" + ) + // An early return on the switch would skip recording what is on screen. + assertFalse(announce.contains("if (!graph.hosts.notificationsEnabled"), "announce returns before recording what is on screen:\n$announce") + assertFalse(announce.contains("claimAnnounceable"), "announce ignores what is on screen:\n$announce") + } + + @Test + fun `the background check announces through the listing, not from a stale snapshot`() { + val work = AppSourcePins.blockAfter(notifier, "override suspend fun doWork()") + assertTrue(work.contains("session.refreshNow(RelayApprovalGate.Trigger.BACKGROUND)")) + assertFalse(work.contains("InboxNotifier.announce("), "the worker announces a second time, without what is on screen:\n$work") + } + + @Test + fun `the Inbox tab and the terminal say what they show while they are started`() { + val inbox = AppSourcePins.ui("InboxTab.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(inbox, "LifecycleStartEffect(hostId)"), + "val showing = session.onScreen.showInbox()", + "onStopOrDispose { showing.close() }" + ) + // Registered before the watch starts, so the first listing already knows; closed after it stops. + // The terminal says at every listing whether its pane shows (the A73 review). + val terminal = AppSourcePins.ui("TerminalScreen.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(terminal, "LifecycleStartEffect(controller)"), + "val showing = session.onScreen.showNode(nodeId) { controller.pane }", + "session.startWatching()", + "onStopOrDispose {", + "session.stopWatching()", + "showing.close()" + ) + // Attached shows the pane, connecting is about to, every overlay covers it. + val pane = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "val pane: OnScreen.Pane") + AppSourcePins.assertInOrder( + pane, + "TermState.Attached -> OnScreen.Pane.SHOWN", + "TermState.Connecting -> OnScreen.Pane.OPENING", + "else -> OnScreen.Pane.HIDDEN" + ) + // On the attach, what the pane shows of the latest listing is recorded, and nothing announced. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(terminal, "LaunchedEffect(controller, pane)"), + "if (pane == OnScreen.Pane.SHOWN) session.notePaneShown(nodeId)" + ) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(connections, "fun notePaneShown(nodeId: String)"), + "_snapshot.value.status?.inbox?.events", + "graph.hosts.claimLive(hostId, events, OnScreen(nodes = setOf(nodeId)), notify = false)" + ) + } + + @Test + fun `the copy promises background checks and live listings for visible computers`() { + val promise = "Checked about every 15 minutes in the background, and live while this computer or All computers is on screen" + val settings = AppSourcePins.ui("SettingsScreen.kt") + assertTrue(settings.contains("\"$promise.\""), "Settings does not say what is live") + assertFalse(settings.contains("live while a computer is open")) + val kdoc = notifier.replace(Regex("\\s*\\n\\s*\\*\\s*"), " ") + assertTrue(kdoc.contains("checked about every 15 minutes in the background, and live while the computer or All computers is on screen")) + val readme = File(InteropHarness.repoRoot, "android/README.md").readText().replace(Regex("\\s+"), " ") + assertTrue(readme.contains("checked about every 15 minutes in the background (WorkManager's floor), and live while the computer or All computers is on screen")) + assertFalse(readme.contains("live every 8 seconds while a computer is open")) + assertTrue(AppSourcePins.ui("AllComputersScreen.kt").contains("session.startWatching(Trigger.AUTO)")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionCreationTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionCreationTest.kt new file mode 100644 index 0000000000..7cfcfda174 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionCreationTest.kt @@ -0,0 +1,155 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.* +import kotlinx.coroutines.* +import kotlinx.serialization.json.* +import kotlin.test.* + +class ManagedSessionCreationTest { + private class Memory : ManagedSessionCreation.Storage { + var encoded: String? = null + var fail = false + override fun read() = encoded + override fun write(encoded: String) { if (fail) throw java.io.IOException("disk refused commit"); this.encoded = encoded } + } + private val instance = "11111111-1111-4111-8111-111111111111" + private val choice = ManagedSessionChoice("project-1", "shell", title = "Terminal") + private fun request(c: ManagedSessionChoice = choice, n: Int = 1) = PreparedManagedSession("/profile", instance, + "22222222-2222-4222-8222-${n.toString().padStart(12, '0')}", 1000, + "33333333-3333-4333-8333-${n.toString().padStart(12, '0')}", c, + """{"version":1,"instance":"$instance","pid":1,"updatedAt":1000,"methods":["sessions.createManagedV1"],"remoteProjects":false}""") + private fun receipt(q: PreparedManagedSession) = ManagedSessionReceipt(q.creationId, "term-abc-${q.creationId.takeLast(12)}", q.choice.projectId, + q.hostInstance, "node-terminal", "nt-term-abc-${q.creationId.takeLast(12)}", "%0", 123, + "linux:11111111-1111-1111-1111-111111111111:42", "1000") + + @Test fun `admission serializes rapid taps and dispatch commits its original transaction first`() = runBlocking { + val disk = Memory(); val ready = CompletableDeferred(); var prepares = 0; var calls = 0; var shown = 0 + val creation = ManagedSessionCreation(this, disk, { prepares++; ready.await(); request(it) }, { q -> + calls++; val saved = Json.parseToJsonElement(disk.encoded!!).jsonObject.getValue("pending").jsonObject + assertEquals("sent", saved.getValue("phase").jsonPrimitive.content) + assertEquals(q, ManagedSessions.prepared(saved.getValue("request").jsonObject)); receipt(q) + }) + val ticket = creation.show() + assertTrue(creation.submit(ticket, choice) { shown++ }) + assertFalse(creation.submit(ticket, choice) { shown++ }) + yield(); assertEquals(1, prepares); assertEquals(0, calls) + creation.hide(ticket); ready.complete(Unit); yield() + assertEquals(1, calls); assertEquals(0, shown) + val result = assertIs(creation.state.value) + assertNotNull(creation.receiptFor(result.adoption.receipt.nodeId)) + assertNull(creation.takeReady(ticket), "A stale dialog may not navigate") + } + @Test fun `process recovery after dispatch blocks any replay and new nonce until user checks`() = runBlocking { + val disk = Memory(); var calls = 0 + val creation = ManagedSessionCreation(this, disk, { request(it) }, { calls++; throw HostUnansweredException("Lost reply") }) + val ticket = creation.show(); assertTrue(creation.submit(ticket, choice) {}); yield() + assertIs(creation.state.value) + assertFalse(creation.submit(ticket, choice) {}) + val recovered = ManagedSessionCreation(this, disk, { error("Must not re-prepare") }, { error("Must not re-send") }) + val t = recovered.show(); assertIs(recovered.state.value) + assertFalse(recovered.submit(t, choice.copy(projectId = "other")) {}) + assertEquals(1, calls) + assertFalse(recovered.acknowledgeChecked(ticket + 999)) + assertTrue(recovered.acknowledgeChecked(t)); assertIs(recovered.state.value) + } + @Test fun `a failed durable commit cannot dispatch`() = runBlocking { + val disk = Memory().apply { fail = true }; var sent = 0 + val creation = ManagedSessionCreation(this, disk, { request(it) }, { sent++; receipt(it) }) + creation.submit(creation.show(), choice) {}; yield() + assertEquals(0, sent); assertIs(creation.state.value) + } + @Test fun `only a confirmed pre mutation refusal permits changing choices`() = runBlocking { + val disk = Memory(); val choices = ArrayList(); var fail = true + val creation = ManagedSessionCreation(this, disk, { choices += it; request(it, choices.size) }, { + if (fail) throw ManagedSessionRefusedException("Folder disappeared before creation") else receipt(it) + }) + val ticket = creation.show(); creation.submit(ticket, choice) {}; yield() + assertIs(creation.state.value); fail = false + assertTrue(creation.submit(ticket, choice.copy(projectId = "changed")) {}); yield() + assertEquals(listOf("project-1", "changed"), choices.map { it.projectId }) + assertIs(creation.state.value) + } + @Test fun `a ready result survives process death and consuming navigation retains attachment proof`() = runBlocking { + val disk = Memory() + val creation = ManagedSessionCreation(this, disk, { request(it) }, { receipt(it) }) + creation.submit(creation.show(), choice) {}; yield() + val recovered = ManagedSessionCreation(this, disk, { error("No second preparation") }, { error("No second creation") }) + assertIs(recovered.state.value) + val adoption = assertNotNull(recovered.takeReady(recovered.show())) + val afterNavigation = ManagedSessionCreation(this, disk, { error("No preparation") }, { error("No creation") }) + assertIs(afterNavigation.state.value) + assertEquals(adoption, afterNavigation.receiptFor(adoption.receipt.nodeId)) + afterNavigation.adopted(adoption.receipt.nodeId) + assertNull(ManagedSessionCreation(this, disk, { request(it) }, { receipt(it) }).receiptFor(adoption.receipt.nodeId)) + } + @Test fun `adopting an older receipt preserves another ready phase across restart`() = runBlocking { + val disk = Memory(); var n = 0 + val creation = ManagedSessionCreation(this, disk, { request(it, ++n) }, { receipt(it) }) + var ticket = creation.show(); creation.submit(ticket, choice) {}; yield() + val first = assertNotNull(creation.takeReady(ticket)) + ticket = creation.show(); creation.submit(ticket, choice) {}; yield() + val second = assertIs(creation.state.value).adoption + creation.adopted(first.receipt.nodeId) + val recovered = ManagedSessionCreation(this, disk, { error("No creation") }, { error("No creation") }) + assertEquals(second, assertIs(recovered.state.value).adoption) + assertNull(recovered.receiptFor(first.receipt.nodeId)); assertEquals(second, recovered.receiptFor(second.receipt.nodeId)) + } + @Test fun `eight unadopted receipts cannot be silently evicted by a ninth creation`() = runBlocking { + val disk = Memory(); var n = 0 + val creation = ManagedSessionCreation(this, disk, { request(it, ++n) }, { receipt(it) }) + val nodes = ArrayList() + repeat(8) { + val ticket = creation.show(); assertTrue(creation.submit(ticket, choice) {}); yield() + nodes += assertNotNull(creation.takeReady(ticket)).receipt.nodeId + } + assertFalse(creation.submit(creation.show(), choice) {}); assertEquals(8, n) + for (node in nodes) assertNotNull(creation.receiptFor(node)) + creation.adopted(nodes.first()); assertTrue(creation.submit(creation.show(), choice) {}); yield(); assertEquals(9, n) + } + @Test fun `a conflicting host node identity cannot overwrite an earlier unadopted receipt`() = runBlocking { + val disk = Memory(); var n = 0; var original: ManagedSessionAdoption? = null + val creation = ManagedSessionCreation(this, disk, { request(it, ++n) }, { q -> + val answer = receipt(q) + original?.let { old -> answer.copy(nodeId = old.receipt.nodeId, session = old.receipt.session) } ?: answer + }) + var ticket = creation.show(); creation.submit(ticket, choice) {}; yield() + original = assertNotNull(creation.takeReady(ticket)) + ticket = creation.show(); creation.submit(ticket, choice) {}; yield() + assertIs(creation.state.value) + assertEquals(original, creation.receiptFor(original!!.receipt.nodeId)) + assertEquals(original, ManagedSessionCreation(this, disk, { error("No prepare") }, { error("No create") }).receiptFor(original!!.receipt.nodeId)) + } + @Test fun `cancelled owner after dispatch leaves durable uncertainty without replay`() = runBlocking { + val disk = Memory(); val scope = CoroutineScope(coroutineContext.minusKey(Job) + SupervisorJob()) + val dispatched = CompletableDeferred(); var sent = 0 + val creation = ManagedSessionCreation(scope, disk, { request(it) }, { sent++; dispatched.complete(Unit); awaitCancellation() }) + val ticket = creation.show(); creation.submit(ticket, choice) {}; dispatched.await() + scope.coroutineContext.cancelChildren(); yield() + assertIs(creation.state.value) + assertFalse(creation.submit(ticket, choice) {}); assertEquals(1, sent) + assertIs(ManagedSessionCreation(this, disk, { request(it) }, { receipt(it) }).state.value) + scope.cancel() + } + @Test fun `an already cancelled application scope cannot admit a creation`() = runBlocking { + val scope = CoroutineScope(coroutineContext.minusKey(Job) + SupervisorJob()); scope.cancel() + val creation = ManagedSessionCreation(scope, Memory(), { error("No preparation") }, { error("No dispatch") }) + assertFalse(creation.submit(creation.show(), choice) {}) + assertIs(creation.state.value) + } + @Test fun `a ready checkpoint without its retained adoption cannot downgrade into an ordinary open`() = runBlocking { + val disk = Memory() + val creation = ManagedSessionCreation(this, disk, { request(it) }, { receipt(it) }) + creation.submit(creation.show(), choice) {}; yield() + val saved = Json.parseToJsonElement(disk.encoded!!).jsonObject + disk.encoded = JsonObject(saved + ("receipts" to JsonArray(emptyList()))).toString() + val restored = ManagedSessionCreation(this, disk, { error("No prepare") }, { error("No create") }) + assertIs(restored.state.value) + assertNull(restored.takeReady(restored.show())) + } + @Test fun `unreadable saved checkpoint does not authorize a new session`() = runBlocking { + val disk = Memory().apply { encoded = "not JSON" } + val creation = ManagedSessionCreation(this, disk, { error("No new request") }, { error("No new creation") }) + assertIs(creation.state.value) + assertFalse(creation.submit(creation.show(), choice) {}) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionInteropTest.kt new file mode 100644 index 0000000000..ab5edf7650 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionInteropTest.kt @@ -0,0 +1,143 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.* +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.ssh.SshActions +import kotlinx.serialization.json.* +import java.io.File +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import org.junit.jupiter.api.Assumptions.assumeTrue +import kotlin.test.* + +/** Real TS planner/coordinator/store/PtyManager -> Kotlin -> actual generated POSIX file writer. + * Native PTY/tmux/CLI execution is an explicit recorder. This is not an actual shell/device proof. */ +class ManagedSessionInteropTest { + private fun fixture(trusted: Boolean = true, body: (InteropHarness, File, SshActions) -> Unit) { + assumeTrue(System.getProperty("os.name").let { it.startsWith("Linux") || it.startsWith("Mac") }, + "Managed V1 needs the POSIX tmux host contract") + val home = Files.createTempDirectory("nt-managed-interop-").toFile() + try { + val profile = File(home, ".config/node-terminal") + InteropHarness.start("managed-session", InteropHarness.scratchHomeEnv(home) + mapOf( + "FIXTURE_USERDATA" to profile.path, "FIXTURE_MANAGED_TRUSTED" to if (trusted) "1" else "0" + )).use { f -> + assertEquals("managed-native-recorder", f.ready.s("nativeBoundary")) + assertEquals(profile.path, f.ready.s("userData")) + val client = SshActions { script, _, input, uncertain, limit -> + if (input != null) { assertTrue(uncertain); assertTrue(limit != null) } + execute(script, input) + } + try { body(f, profile, client) } catch (failure: Throwable) { + System.err.println("[managed fixture proof] " + runCatching { snapshot(f) }.getOrNull()) + throw failure + } + } + } finally { home.deleteRecursively() } + } + private fun execute(script: String, input: String?): Pair { + val p = ProcessBuilder("/bin/sh", "-c", script).apply { + environment().clear(); environment()["PATH"] = "/usr/bin:/bin" + }.start() + try { + p.outputStream.use { out -> input?.let { out.write(it.toByteArray(Charsets.UTF_8)) } } + var raw = ""; var error = "" + val stdout = thread { raw = p.inputStream.bufferedReader().readText() } + val stderr = thread { error = p.errorStream.bufferedReader().readText() } + assertTrue(p.waitFor(22, TimeUnit.SECONDS), "managed request exceeded the generated script deadline") + stdout.join(); stderr.join(); assertTrue(error.isBlank(), error) + return p.exitValue() to raw + } finally { p.destroyForcibly() } + } + private fun snapshot(f: InteropHarness): JsonObject { f.command("snapshot"); return f.awaitEvent("snapshot") } + private fun JsonObject.nodes(): JsonArray = getValue("file").jsonObject.getValue("nodes").jsonArray + private fun JsonObject.native(name: String): JsonArray = getValue("native").jsonObject.getValue(name).jsonArray + private fun request(f: InteropHarness, profile: File, client: SshActions, kind: String = "shell", account: String? = null) = + client.prepareManaged(profile.path, ManagedSessionChoice(f.ready.s("projectId")!!, kind, + if (kind == "agent") "claude" else null, account, "Kotlin α ' owned")) { true } + private fun confirmed(client: SshActions, request: PreparedManagedSession): ManagedSessionReceipt = + runCatching { client.createManaged(request) { true } }.getOrElse { + fail("The valid fixture creation must produce a confirmed receipt: $it") + } + + @Test fun `actual shell transaction produces one registered receipt and identical wire retry cannot respawn`() = fixture { f, ud, client -> + val q = request(f, ud, client) + val r = confirmed(client, q) + assertEquals(q.creationId, r.creationId); assertEquals(q.hostInstance, r.hostInstance) + assertEquals("node-terminal", r.socket); assertEquals("nt-" + r.nodeId, r.session) + assertEquals(f.ready.getValue("recorderPid").jsonPrimitive.int, r.panePid) + assertTrue(r.paneBirth.startsWith(if (System.getProperty("os.name").startsWith("Linux")) "linux:" else "darwin:")) + assertFailsWith { client.createManaged(q) { true } } + // Fixture-only adversarial traffic exercises the coordinator's creation-id cache. + // Android intentionally has no retry/reconciliation API after a dispatched uncertainty. + assertEquals(r, confirmed(client, q.copy(nonce = UUID.randomUUID().toString()))) + val proof = snapshot(f) + val node = proof.nodes().single().jsonObject + assertEquals(r.nodeId, node.s("id")); assertEquals("Kotlin α ' owned", node.s("title")) + assertNull(node["pendingLaunch"]); assertNull(node["agentId"]) + assertEquals(1, proof.native("created").size); assertEquals(0, proof.native("launched").size) + assertTrue(proof.native("created").single().jsonObject.getValue("createOnly").jsonPrimitive.boolean) + assertTrue(proof.getValue("broadcasts").jsonArray.any { it.toString().contains("workspace:server-change") }) + } + + @Test fun `actual trusted agent uses host policy shell account and stdin launch then clears inert recovery intent`() = fixture { f, ud, client -> + val q = request(f, ud, client, "agent", "fixture-account") + val r = confirmed(client, q) + assertFailsWith { client.createManaged(q) { true } } + assertEquals(r, confirmed(client, q.copy(nonce = UUID.randomUUID().toString()))) + val proof = snapshot(f) + assertEquals(1, proof.native("launched").size) + val launch = proof.native("launched").single().jsonObject + val text = launch.s("body")!! + assertTrue(text.startsWith("trusted-wrapper claude ")); assertTrue(text.contains("--permission-mode auto")) + assertFalse(text.contains("bypassPermissions")); assertTrue(text.contains("--session-id ")) + assertEquals("/bin/sh", launch.s("shell")); assertEquals("trusted", launch.s("projectEnv")) + assertEquals(File(ud, "claude-accounts/fixture-account").path, launch.s("accountDir")) + assertTrue(launch.getValue("textOnStdin").jsonPrimitive.boolean) + val node = proof.nodes().single().jsonObject + assertEquals(r.nodeId, node.s("id")); assertEquals("fixture-account", node.s("accountId")) + assertEquals("#123abc", node.s("color")); assertNull(node["pendingLaunch"]) + assertEquals(1, proof.native("created").size) + assertFalse(proof.getValue("broadcasts").toString().contains("trusted-wrapper"), + "expanded launch text must stay out of shared recovery nodes and renderer broadcasts") + assertFalse(q.wireRequest().contains("command")); assertFalse(q.wireRequest().contains("cwd")) + } + + @Test fun `actual untrusted shared launch uses global command and System ignores the configured account default`() = fixture(false) { f, ud, client -> + val q = request(f, ud, client, "agent") + val r = confirmed(client, q) + val proof = snapshot(f); assertEquals(1, proof.native("launched").size) + val launch = proof.native("launched").single().jsonObject + assertTrue(launch.s("body")!!.startsWith("global-wrapper claude ")) + assertEquals("/bin/bash", launch.s("shell")); assertEquals(JsonNull, launch["projectEnv"]) + assertEquals(JsonNull, launch["accountDir"]) + assertNull(proof.nodes().single().jsonObject["accountId"]) + assertEquals(r.nodeId, proof.nodes().single().jsonObject.s("id")) + } + + @Test fun `actual service restart cannot adopt a journal receipt or replay a prior agent launch`() = fixture { f, ud, client -> + val q = request(f, ud, client, "agent") + confirmed(client, q) + f.command("restart"); f.awaitEvent("restarted") + assertFailsWith { client.createManaged(q) { true } } + val newInstance = request(f, ud, client, "agent").copy(creationId = q.creationId) + assertNotEquals(q.hostInstance, newInstance.hostInstance) + assertFailsWith { client.createManaged(newInstance) { true } } + val proof = snapshot(f) + assertEquals(1, proof.nodes().size); assertEquals(1, proof.native("created").size) + assertEquals(1, proof.native("launched").size) + } + + @Test fun `actual changed choice with the same creation identity stays uncertain without another pane`() = fixture { f, ud, client -> + val q = request(f, ud, client) + val r = confirmed(client, q) + val changed = q.copy(nonce = UUID.randomUUID().toString(), choice = q.choice.copy(title = "Different")) + assertFailsWith { client.createManaged(changed) { true } } + val proof = snapshot(f) + assertEquals(1, proof.native("created").size); assertEquals(0, proof.native("launched").size) + assertEquals(r.nodeId, proof.nodes().single().jsonObject.s("id")) + assertEquals(q.choice.title, proof.nodes().single().jsonObject.s("title")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionTest.kt new file mode 100644 index 0000000000..2f06f898ff --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionTest.kt @@ -0,0 +1,99 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.* +import dev.nodeterm.protocol.ssh.ManagedViewHandshake +import dev.nodeterm.protocol.ssh.SshActions +import dev.nodeterm.protocol.ssh.SshActionsScripts +import kotlinx.serialization.json.* +import java.io.ByteArrayInputStream +import kotlin.test.* + +class ManagedSessionTest { + private val instance = "11111111-1111-4111-8111-111111111111" + private val nonce = "22222222-2222-4222-8222-222222222222" + private val creation = "33333333-3333-4333-8333-333333333333" + private val stamp = 1_000_000L + private fun advertisement(method: String = ManagedSessions.METHOD) = """{"version":1,"instance":"$instance","pid":1,"updatedAt":$stamp,"methods":["$method"],"remoteProjects":true}""" + private fun choice() = ManagedSessionChoice("project-1", "agent", "claude", "account_1", "Claude") + private fun request() = PreparedManagedSession("/profile", instance, nonce, stamp, creation, choice(), advertisement()) + private fun receipt(request: PreparedManagedSession = request()) = ManagedSessionReceipt(request.creationId, "term-abc-1234", request.choice.projectId, + request.hostInstance, "node-terminal", "nt-term-abc-1234", "%0", 123, "linux:11111111-1111-1111-1111-111111111111:42", "1234") + private fun response(request: PreparedManagedSession, result: JsonElement = receipt(request).toJson()) = buildJsonObject { + put("version", 1); put("instance", request.hostInstance); put("nonce", request.nonce); put("ok", true); put("result", result) + }.toString() + + @Test fun `choice is typed and does not accept executable payload or unsupported identities`() { + for (change in listOf<() -> Unit>( + { choice().copy(kind = "command") }, { choice().copy(agentId = "custom") }, + { choice().copy(kind = "shell") }, { choice().copy(accountId = "../account") }, + { choice().copy(title = "x".repeat(121)) }, { choice().copy(title = "bad\u001b") }, + { choice().copy(cols = 1) }, { choice().copy(rows = 501) }, { choice().copy(projectId = "bad\nproject") } + )) assertFailsWith { change() } + val wire = Json.parseToJsonElement(request().wireRequest()).jsonObject + assertEquals(setOf("version", "instance", "nonce", "issuedAt", "method", "params"), wire.keys) + assertEquals(setOf("creationId", "projectId", "kind", "agentId", "accountId", "title", "cols", "rows"), wire.getValue("params").jsonObject.keys) + assertFalse(wire.toString().contains("command")); assertFalse(wire.toString().contains("cwd")) + assertEquals(ManagedSessionChoice("p", "shell", cols = 500, rows = 500), ManagedSessions.choice(ManagedSessionChoice("p", "shell", cols = 500, rows = 500).params(creation))) + } + @Test fun `receipt binds request profile generation and exact host minted node without numeric coercion`() { + val q = request(); val r = receipt(q) + assertEquals(r, ManagedSessions.receipt(r.toJson(), q)) + assertEquals(q, ManagedSessions.prepared(q.toJson())) + for ((key, value) in listOf( + "creationId" to JsonPrimitive(nonce), "hostInstance" to JsonPrimitive(nonce), "projectId" to JsonPrimitive("other"), + "socket" to JsonPrimitive("nodeterm-rmt"), "session" to JsonPrimitive("nt-other"), "paneId" to JsonPrimitive("%1;run-shell"), + "nodeId" to JsonPrimitive("phone-$creation"), "nodeId" to JsonPrimitive("term-abc-$(evil)"), + "panePid" to JsonPrimitive("123"), "panePid" to JsonPrimitive(4294967419L), "panePid" to JsonPrimitive(0), + "paneBirth" to JsonPrimitive("linux:not-a-boot-id:42"), "paneBirth" to JsonPrimitive("darwin:unverified\n"), + "sessionCreated" to JsonPrimitive("0"), "sessionCreated" to JsonPrimitive(1234), "version" to JsonPrimitive("1") + )) assertFailsWith(key) { ManagedSessions.receipt(JsonObject(r.toJson() + (key to value)), q) } + } + @Test fun `managed creation advertisement is separate from legacy registration and old hosts`() { + for ((method, available) in listOf("node.wake" to false, ManagedSessions.METHOD to true)) { + val client = SshActions { _, _, _, _, _ -> 0 to "NT-ACTIONS-1\t$stamp\n${advertisement(method)}" } + client.probe("/profile") + assertEquals(available, client.capabilities.managedCreate) + assertFalse(client.capabilities.registerNode) + } + assertFalse(HostCapabilities(true, true, true, true, true).managedCreate) + } + @Test fun `one dispatch carries the original nonce choices and confirmed mutation flags`() { + val q = request(); var writes = 0 + val client = SshActions { script, timeout, input, uncertain, limit -> + if (input == null) 0 to "NT-ACTIONS-1\t$stamp\n${advertisement()}" else { + writes++; assertTrue(script.contains("/${q.nonce}.request"), "submission path must retain the original nonce"); assertEquals(q.wireRequest(), input); assertEquals(20L, timeout) + assertTrue(uncertain); assertEquals(SshActionsScripts.MAX_BYTES + 128, limit) + 0 to "NT-ACTIONS-REPLY\n${response(q)}\n" + } + } + assertEquals(receipt(q), client.createManaged(q) { true }); assertEquals(1, writes) + assertFailsWith { client.call("/profile", ManagedSessions.METHOD, q.choice.params(q.creationId), "new") { true } } + assertEquals(1, writes, "Creation must not enter the generic fresh-nonce method") + } + @Test fun `changed service or ownership refuses before publication and sent failures remain uncertain`() { + val q = request() + for (changed in listOf("instance", "ownership")) { + var writes = 0 + val client = SshActions { _, _, input, _, _ -> + if (input != null) writes++ + 0 to "NT-ACTIONS-1\t$stamp\n${advertisement().let { if (changed == "instance") it.replace(instance, nonce) else it }}" + } + assertFailsWith { client.createManaged(q) { changed != "ownership" } } + assertEquals(0, writes) + } + for ((exit, raw) in listOf(null to "", null to "NT-ACTIONS-REPLY\n${response(q)}", 4 to "NT-ACTIONS-UNCERTAIN", 0 to "NT-ACTIONS-REPLY\n${response(q, receipt(q).copy(panePid = 0).toJson())}")) { + val client = SshActions { _, _, input, _, _ -> if (input == null) 0 to "NT-ACTIONS-1\t$stamp\n${advertisement()}" else exit to raw } + assertFailsWith { client.createManaged(q) { true } } + } + } + @Test fun `confirmation requires exactly the private first line without consuming terminal output`() { + val bytes = "NT-MANAGED-VIEW /dev/pts/123\r\n\u001b[2JUnicode α".toByteArray() + val input = ByteArrayInputStream(bytes) + assertEquals("/dev/pts/123", ManagedViewHandshake.read(input)) + assertEquals("\u001b[2JUnicode α", String(input.readBytes(), Charsets.UTF_8)) + for (bad in listOf("/dev/pts/1\n", "NT-MANAGED-VIEW /etc/passwd\n", "NT-MANAGED-VIEW /dev/pts/1;evil\n", "x".repeat(257), "")) { + assertFailsWith { ManagedViewHandshake.read(ByteArrayInputStream(bad.toByteArray())) } + } + assertEquals("/dev/ttys002", ManagedViewHandshake.read(ByteArrayInputStream("NT-MANAGED-VIEW /dev/ttys002\n".toByteArray()))) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionWiringTest.kt new file mode 100644 index 0000000000..9283c10030 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManagedSessionWiringTest.kt @@ -0,0 +1,55 @@ +package dev.nodeterm.protocol + +import kotlin.test.* + +/** Native lifecycle/persistence calls are pinned; request, receipt and admission behavior run on JVM. */ +class ManagedSessionWiringTest { + @Test fun `each host owns a durable creation store which commits before any remote mutation`() { + val store = AppSourcePins.app("data/HostStore.kt") + val storage = AppSourcePins.blockAfter(store, "fun managedCreationStorage(") + assertTrue(storage.contains("managedCreation.\$hostId"), "a receipt must not be shared across host profiles") + assertTrue(storage.contains(".commit()"), "apply() may lose a sent checkpoint on process death") + assertTrue(storage.contains("throw java.io.IOException"), "failed durable admission must fail creation") + assertTrue(store.contains("remove(\"managedCreation.\$id\")"), "forget removes only that host's creation journal") + val session = AppSourcePins.app("conn/ConnectionManager.kt") + val creation = session.substringAfter("val managedSessionCreation =").substringBefore("private val") + assertTrue(creation.contains("ManagedSessionCreation(graph.scope"), "dialog dismissal must not cancel an accepted host transaction") + assertTrue(creation.contains("managedCreationStorage(hostId)")) + assertTrue(creation.contains("as? SshHostConnection"), "managed creation cannot fall through to relay registration") + assertTrue(creation.contains("prepareManagedSession(choice)")); assertTrue(creation.contains("createManagedSession(request)")) + } + @Test fun `managed New freezes typed choices before any phone launch command or registration`() { + val dialog = AppSourcePins.blockAfter(AppSourcePins.ui("SessionsTab.kt"), "fun NewSessionDialog(") + val branch = AppSourcePins.blockAfter(dialog, "if (onManagedCreate != null)") + AppSourcePins.assertInOrder(branch, "ManagedSessionChoice(projectId = p.id", "kind = if (a == null)", "agentId = a?.id", "accountId = acct", "return@start") + assertFalse(branch.contains("Launch.launchCommand")); assertFalse(branch.contains("Launch.newNodeId")) + AppSourcePins.assertInOrder(dialog, "if (onManagedCreate != null)", "return@start", "Launch.launchCommand") + val host = AppSourcePins.ui("HostScreen.kt") + AppSourcePins.assertInOrder(host, "val managedNew = offersSshTerminal", "capabilities?.managedCreate == true") + assertTrue(host.contains("managedCreation.submit(ticket, choice)")) + assertTrue(host.contains("ManagedSessionCreation.State.Uncertain")); assertTrue(host.contains("managedTicket?.let(managedCreation::acknowledgeChecked)")) + AppSourcePins.assertInOrder(host, "managedCreation.takeReady(ticket)", "ready.receipt.nodeId") + } + @Test fun `a retained receipt always adopts over SSH and suppresses PhoneLaunch and cold resume`() { + val attach = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "private fun attach()") + AppSourcePins.assertInOrder(attach, "receiptFor(nodeId)", "managed != null -> session.ensureConnected()", "it.kind != TransportKind.SSH", "conn.attachManagedSession(managed", "session.managedSessionCreation.adopted(nodeId)") + assertTrue(attach.contains("if (managed == null && hint != null) PendingLaunches.take(nodeId)")) + assertTrue(attach.contains("if (managed == null && launch == null && slot.isCurrent(ticket)) afterAttach")) + assertTrue(attach.contains("if (launch != null || managed != null) resumeOffer = null")) + assertTrue(attach.contains("managedReceiptBlocked = managed != null")) + val discard = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "fun discardManagedReceipt()") + AppSourcePins.assertInOrder(discard, "if (!managedReceiptBlocked || attached)", "managedSessionCreation.adopted(nodeId)", "session.refresh()") + assertFalse(discard.contains("attach(")); assertFalse(discard.contains("PendingLaunches")) + val screen = AppSourcePins.ui("TerminalScreen.kt") + assertTrue(screen.contains("I checked the computer")) + AppSourcePins.assertInOrder(screen, "controller.discardManagedReceipt()", "nav.pop()") + } + @Test fun `accepted managed creation cannot navigate a stopped superseded or forgotten screen`() { + val host = AppSourcePins.ui("HostScreen.kt") + val open = AppSourcePins.blockAfter(host, "fun openManaged(") + AppSourcePins.assertInOrder(open, "!hostStarted", "managedTicket != ticket", "nav.top.key != screenKey", "graph.hosts.get(hostId) == null", "takeReady(ticket)", "nav.push(") + val life = AppSourcePins.blockAfter(host, "LifecycleStartEffect(hostId)") + assertTrue(life.contains("managedTicket = managedCreation.show()")) + assertTrue(life.contains("managedTicket?.let(managedCreation::hide)")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManualHostTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManualHostTest.kt new file mode 100644 index 0000000000..b0e82f6e75 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ManualHostTest.kt @@ -0,0 +1,518 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.host.LegRouting.Leg +import dev.nodeterm.protocol.host.LegRouting.RelayLeg +import dev.nodeterm.protocol.host.NeedsRelayException +import dev.nodeterm.protocol.host.SshAuthRefusedException +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.pairing.PairingResult +import dev.nodeterm.protocol.pairing.SshIdentity +import dev.nodeterm.protocol.ssh.HostKeyChangedException +import dev.nodeterm.protocol.ssh.HostKeyPin +import dev.nodeterm.protocol.ssh.ManualHost +import dev.nodeterm.protocol.ssh.ManualHost.Check +import dev.nodeterm.protocol.ssh.SshFallback +import dev.nodeterm.protocol.ssh.SshHostConnection +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.transport.TransportException +import net.schmizz.sshj.userauth.UserAuthException +import org.apache.sshd.server.SshServer +import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator +import org.apache.sshd.server.config.keys.AuthorizedKeysAuthenticator +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider +import org.apache.sshd.server.session.ServerSession +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import java.util.concurrent.TimeoutException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A27, part b: a computer added by its SSH address ("Add SSH server") instead of a pairing code + * — a headless Server Edition (no pairing service exists) or a dev host the phone reaches only over + * SSH. The form's checks, the record and its persisted shape, the one-line key install (run for real + * under `/bin/sh`), the first connect against a real SSH server that reads that `authorized_keys`, and + * the routing that keeps such a computer SSH-only with no relay offer anywhere. + */ +class ManualHostTest { + private val identity = SshIdentity.generate() + + // ---- the form --------------------------------------------------------------------------------- + + private fun ok(host: String, port: String = "", user: String = "dev", name: String = ""): ManualHost.Address = + assertIs(ManualHost.check(host, port, user, name), "$host:$port $user").address + + private fun invalid(host: String, port: String = "", user: String = "dev"): Check.Invalid = + assertIs(ManualHost.check(host, port, user), "$host:$port $user") + + @Test + fun `host names, IPv4 and IPv6 addresses are accepted, port 22 by default and the host as the name`() { + assertEquals(ManualHost.Address("devbox.local", 22, "dev", "devbox.local"), ok(" devbox.local ")) + assertEquals(ManualHost.Address("192.168.1.20", 2222, "dev", "Build box"), ok("192.168.1.20", "2222", name = " Build box ")) + assertEquals("fe80::1%wlan0", ok("fe80::1%wlan0").host) + assertEquals("2001:db8::7", ok("[2001:db8::7]").host, "brackets are how an IPv6 address is often written") + assertEquals("my_host-1.example.com.", ok("my_host-1.example.com.").host) + assertEquals("alice@corp.example", ok("box", user = "alice@corp.example").user, "a domain login is a user name") + assertEquals("x".repeat(ManualHost.MAX_NAME), ok("box", name = "x".repeat(200)).name) + assertEquals("box", ok("box", name = "\u0007\n").name, "control characters only: no name, so the host") + } + + @Test + fun `each wrong field says what is wrong, and a right one says nothing`() { + invalid("").let { assertTrue(it.host!!.contains("address")); assertNull(it.port); assertNull(it.user) } + assertTrue(invalid("alice@devbox").host!!.contains("User"), "the user typed in the address field") + assertTrue(invalid("devbox:2222").host!!.contains("Port"), "the port typed after the address") + assertTrue(invalid("ssh://devbox").host!!.contains("ssh://")) + assertTrue(invalid("dev box").host!!.contains("spaces")) + assertTrue(invalid("-oProxyCommand=x").host != null, "never something that reads as an option") + assertTrue(invalid("dev..box").host != null) + assertTrue(invalid("[zz::1]").host!!.contains("IPv6")) + assertTrue(invalid("box;rm").host != null) + for (port in listOf("0", "65536", "22a", "-1", "123456")) assertTrue(invalid("box", port).port != null, port) + assertEquals(1, ok("box", "1").port) + assertEquals(65535, ok("box", "65535").port) + for (user in listOf("", "a b", "-root", "a:b", "x".repeat(65), "a\tb")) assertTrue(invalid("box", user = user).user != null, "[$user]") + val all = invalid("", "0", "") + assertTrue(all.host != null && all.port != null && all.user != null, "every field is checked at once") + } + + @Test + fun `an address already in the list is found, paired or added, whatever the host name's case`() { + val paired = PairedHost.from( + PairingPayload.parse("""{"v":1,"host":"DevBox.local","user":"dev","token":"t","pairPort":1,"nodeterm":true,"name":"Box"}""")!!, + PairingResult("dev-1", "tok", null, null), + now = 1 + ) + assertEquals(paired, ManualHost.existing(listOf(paired), ok("devbox.local"))) + assertNull(ManualHost.existing(listOf(paired), ok("devbox.local", "2222")), "another port is another login") + assertNull(ManualHost.existing(listOf(paired), ok("devbox.local", user = "root")), "another user too") + } + + // ---- the record ------------------------------------------------------------------------------- + + @Test + fun `the record is SSH-only with its pin, and survives a JSON round trip`() { + val address = ok("srv.example", "2200", "ops", "Server") + val h = ManualHost.record(address, "SHA256:abc", id = "ssh-1", now = 7) + assertTrue(h.manual && h.sshAvailable) + assertNull(h.relay) + assertNull(h.hostKeyB64) + assertNull(h.relayHostKeyB64) + assertEquals("SHA256:abc", h.sshHostKeyFingerprint) + assertEquals("Over SSH", h.sshLegName) + assertEquals(h, PairedHost.fromJson(h.toJson())) + assertEquals(JsonPrimitive(true), h.toJson()["manual"]) + assertTrue(ManualHost.newId().startsWith(ManualHost.ID_PREFIX)) + assertFailsWith("no record without the pin the first connect made") { + ManualHost.record(address, "") + } + } + + @Test + fun `a paired computer's record is what it was, and an older build reads an added one as SSH with no relay`() { + // A paired record carries no `manual` key, so what a build before A27 wrote and reads is unchanged. + val paired = PairedHost.from( + PairingPayload.parse("""{"v":1,"host":"10.0.0.2","user":"u","token":"t","pairPort":1,"nodeterm":true,"name":"Box"}""")!!, + PairingResult("dev-1", "tok", null, null), + now = 5 + ) + assertFalse("manual" in paired.toJson()) + assertFalse(paired.manual) + assertEquals("On your network", paired.sshLegName) + // A build that predates the flag ignores the key and reads the rest: SSH available, no relay + // block, no box key and the same pin — so it connects to it over SSH. + val added = ManualHost.record(ok("box"), "SHA256:pin", id = "ssh-2", now = 9).toJson() + assertEquals(JsonPrimitive(true), added["sshAvailable"]) + assertFalse("relay" in added || "hostKeyB64" in added) + assertEquals(JsonPrimitive("SHA256:pin"), added["sshHostKeyFingerprint"]) + assertEquals(listOf("id", "name", "host", "port", "user", "sshAvailable", "sshHostKeyFingerprint", "pairedAt", "manual"), added.keys.toList()) + } + + @Test + fun `a computer added by address stays added by address after an older build saved the list without the flag`() { + // The review of A27b: a build that predates `manual` rewrites the WHOLE list on its next save + // (pairing or forgetting any computer, a late relay adoption) with no `manual` key, and may have + // adopted a relay for it meanwhile (its adoption has no host-key check to stop it). Coming back + // to this build must not turn it into a paired computer: route choices, a relay adoption and + // "turn on remote access" advice. The id is the one thing that survives the round trip. + val added = ManualHost.record(ok("box"), "SHA256:pin", now = 9) + val olderBuildSaved = JsonObject( + added.toJson() - "manual" + mapOf( + "hostKeyB64" to JsonPrimitive("A".repeat(43) + "="), + "relay" to JsonObject( + mapOf( + "hostId" to JsonPrimitive("h"), + "hostPublicKeyB64" to JsonPrimitive("A".repeat(43) + "="), + "relayEndpoint" to JsonPrimitive("wss://relay.nodeterm.dev") + ) + ) + ) + ) + val back = PairedHost.fromJson(olderBuildSaved)!! + assertTrue(back.manual, "the id says it was added by address") + assertNull(back.relay) + assertNull(back.relayHostKeyB64) + assertEquals(added, back) + assertEquals(LegRouting.RelayLeg.ADDED_OVER_SSH, LegRouting.relayLeg(relayConfigured = false, sshOnlyRoute = true, addedOverSsh = back.manual)) + // And the next save writes the flag again. + assertEquals(JsonPrimitive(true), back.toJson()["manual"]) + // A paired computer's id is the desktop's randomUUID(), which never carries the prefix. + val paired = PairedHost.from( + PairingPayload.parse("""{"v":1,"host":"10.0.0.2","user":"u","token":"t","pairPort":1,"nodeterm":true,"name":"Box"}""")!!, + PairingResult(java.util.UUID.randomUUID().toString(), "tok", null, null) + ) + assertFalse(PairedHost.fromJson(paired.toJson())!!.manual) + assertEquals(PairedHost.MANUAL_ID_PREFIX, ManualHost.ID_PREFIX) + } + + @Test + fun `a record added by address never carries a relay leg, even when the stored JSON says one`() { + val tampered = JsonObject( + ManualHost.record(ok("box"), "SHA256:pin", id = "ssh-3").toJson() + mapOf( + "sshAvailable" to JsonPrimitive(false), + "hostKeyB64" to JsonPrimitive("A".repeat(43) + "="), + "relay" to JsonObject( + mapOf( + "hostId" to JsonPrimitive("h"), + "hostPublicKeyB64" to JsonPrimitive("A".repeat(43) + "="), + "relayEndpoint" to JsonPrimitive("wss://relay.nodeterm.dev") + ) + ) + ) + ) + val h = PairedHost.fromJson(tampered)!! + assertTrue(h.manual) + assertTrue(h.sshAvailable, "SSH is its only route") + assertNull(h.relay) + assertNull(h.relayHostKeyB64) + } + + // ---- the key install -------------------------------------------------------------------------- + + private fun sh(command: String, home: File): Int { + val pb = ProcessBuilder("/bin/sh", "-c", command).redirectErrorStream(true) + pb.environment().clear() + pb.environment().putAll(mapOf("HOME" to home.path, "PATH" to "/usr/bin:/bin")) + val p = pb.start() + val out = p.inputStream.bufferedReader().readText() + val code = p.waitFor() + assertEquals(0, code, out) + return code + } + + private fun mode(f: File) = PosixFilePermissions.toString(Files.getPosixFilePermissions(f.toPath())) + + @Test + fun `the install line adds the key once, after a newline the file lacked, with the modes sshd wants`() { + assumeTrue(File("/bin/sh").canExecute()) + val line = ManualHost.authorizedKeysLine(identity) + assertTrue(line.startsWith("ssh-ed25519 ") && line.endsWith(" ${ManualHost.KEY_COMMENT}"), line) + val command = ManualHost.installCommand(line) + assertTrue(command.startsWith("sh -c '") && command.endsWith("'") && command.count { it == '\'' } == 2, command) + + val fresh = Files.createTempDirectory("nt-manual").toFile() + try { + sh(command, fresh) + val keys = File(fresh, ".ssh/authorized_keys") + assertEquals("$line\n", keys.readText()) + assertEquals("rwx------", mode(File(fresh, ".ssh"))) + assertEquals("rw-------", mode(keys)) + sh(command, fresh) + assertEquals("$line\n", keys.readText(), "running it again adds nothing") + } finally { + fresh.deleteRecursively() + } + + val existing = Files.createTempDirectory("nt-manual").toFile() + try { + val keys = File(existing, ".ssh").apply { mkdirs() }.resolve("authorized_keys") + keys.writeText("ssh-rsa AAAAother laptop") // no newline at the end + sh(command, existing) + assertEquals("ssh-rsa AAAAother laptop\n$line\n", keys.readText(), "the other key's line stays whole") + } finally { + existing.deleteRecursively() + } + assertFailsWith("nothing but a key line goes inside the quotes") { + ManualHost.installCommand("ssh-ed25519 AAAA x'; rm -rf ~; echo '") + } + } + + // ---- the first connect -------------------------------------------------------------------------- + + private fun server(home: File, hostKey: File): SshServer = SshServer.setUpDefaultServer().apply { + host = "127.0.0.1" + port = 0 + keyPairProvider = SimpleGeneratorHostKeyProvider(hostKey.toPath()) + // What sshd does with the key: read the user's authorized_keys, the file the install line writes. + publickeyAuthenticator = object : AuthorizedKeysAuthenticator(File(home, ".ssh/authorized_keys").toPath()) { + override fun isValidUsername(username: String?, session: ServerSession?) = username == "dev" + } + start() + } + + private class MemoryPin(var value: String? = null) : HostKeyPin { + override fun pinned() = value + override fun pin(fingerprint: String) { + value = fingerprint + } + } + + @Test + fun `connect pins only once the computer accepts the phone's key, and later connects verify that pin`() { + assumeTrue(File("/bin/sh").canExecute()) + val root = Files.createTempDirectory("nt-manual").toFile() + val home = File(root, "home").apply { mkdirs() } + val sshd = server(home, File(root, "hostkey.ser")) + try { + val address = ok("127.0.0.1", sshd.port.toString(), "dev", "Server Edition") + // Before the key is installed: the server answers and refuses us. No record, no pin, and the + // message says what to do. + val refused = assertFailsWith { ManualHost.connectFirst(address, identity) } + assertEquals(ManualHost.keyNotAccepted(address), refused.message) + assertTrue(refused.message!!.contains("authorized_keys")) + val pin = MemoryPin() + assertFailsWith { + SshHostConnection.connect(address.host, address.port, address.user, identity, pin) + } + assertNull(pin.value, "a refusing server is never pinned (A49)") + + // The user runs the install line on the computer; now the first connect adds it. + sh(ManualHost.installCommand(ManualHost.authorizedKeysLine(identity)), home) + val added = ManualHost.connectFirst(address, identity, id = "ssh-9", now = 3) + val serverKey = sshd.keyPairProvider.loadKeys(null).first().public + assertEquals(SshHostConnection.fingerprint(serverKey), added.sshHostKeyFingerprint, "the pin is the key of the server that let us in") + assertEquals(ManualHost.record(address, added.sshHostKeyFingerprint!!, "ssh-9", 3), added) + + // A later connect verifies against the kept pin… + val kept = PairedHost.fromJson(added.toJson())!! + SshHostConnection.connect(kept.host, kept.port, kept.user, identity, MemoryPin(kept.sshHostKeyFingerprint)).close() + // …and another user on the same computer is refused like a missing key. + val other = ok("127.0.0.1", sshd.port.toString(), "root") + assertEquals(ManualHost.keyNotAccepted(other), assertFailsWith { ManualHost.connectFirst(other, identity) }.message) + } finally { + sshd.stop(true) + } + + // Another server at that address (a reinstall, or something else answering there): refused, + // and with no relay to fall back to, the stop says to forget and add it again. + val impostor = server(home, File(root, "other-hostkey.ser")) + try { + val kept = ManualHost.record(ok("127.0.0.1", impostor.port.toString(), "dev"), "SHA256:the-real-one") + val changed = assertFailsWith { + SshHostConnection.connect(kept.host, kept.port, kept.user, identity, MemoryPin(kept.sshHostKeyFingerprint)) + } + val next = assertIs(SshFallback.afterFailure(changed, false, false, addedOverSsh = kept.manual)) + assertTrue(next.message.contains(SshFallback.ADDED_OVER_SSH_KEY_ADVICE), next.message) + assertFalse(next.message.contains("remote access"), "nothing on the computer can turn a relay on for it") + } finally { + impostor.stop(true) + root.deleteRecursively() + } + } + + @Test + fun `a computer that does not answer says how to check the address, and adds nothing`() { + val dead = java.net.ServerSocket(0).use { it.localPort } // closed again: nothing listens there + val e = assertFailsWith { + ManualHost.connectFirst(ok("127.0.0.1", dead.toString()), identity, connectTimeoutMs = 2_000) + } + assertTrue(e.message!!.endsWith(ManualHost.UNREACHABLE_ADVICE), e.message) + } + + @Test + fun `an authentication that could not finish is not a refused key`() { + // The review of A27b, against sshj 0.39's own shapes: SSHClient.auth throws "Exhausted available + // authentication methods" whenever no method succeeded, with the cause of the last one's + // failure behind it; UserAuthImpl's promise chains an auth timeout and a transport error + // delivered while it waited into a UserAuthException. Only the cause-less one is a refusal. + val exhausted = "Exhausted available authentication methods" + assertTrue(SshHostConnection.isAuthRefusal(UserAuthException(exhausted, null as Throwable?))) + val timedOut = UserAuthException(exhausted, UserAuthException(TimeoutException("Timeout expired: 30000 MILLISECONDS"))) + assertFalse(SshHostConnection.isAuthRefusal(timedOut)) + assertIs(SshHostConnection.authFailureCause(timedOut)) + val dropped = UserAuthException(exhausted, UserAuthException(TransportException(DisconnectReason.CONNECTION_LOST, "Broken transport"))) + assertFalse(SshHostConnection.isAuthRefusal(dropped)) + assertFalse(SshHostConnection.isAuthRefusal(UserAuthException(java.net.SocketException("Connection reset")))) + assertFalse(SshHostConnection.isAuthRefusal(java.io.IOException("not an auth failure at all"))) + } + + @Test + fun `a computer that drops the connection during login is told to check the network, not to add the key`() { + val root = Files.createTempDirectory("nt-manual-drop").toFile() + // A server that answers, then goes away while it is deciding about the key: what a VPN dropping + // or a server giving up mid-login looks like to the phone. sshj reports it as a UserAuthException. + val sshd = SshServer.setUpDefaultServer().apply { + host = "127.0.0.1" + port = 0 + keyPairProvider = SimpleGeneratorHostKeyProvider(File(root, "hostkey.ser").toPath()) + publickeyAuthenticator = PublickeyAuthenticator { _, _, session -> + session.close(true) + false + } + start() + } + try { + val address = ok("127.0.0.1", sshd.port.toString()) + val pin = MemoryPin() + val e = assertFailsWith { + SshHostConnection.connect(address.host, address.port, address.user, identity, pin, connectTimeoutMs = 5_000) + } + assertFalse(e is SshAuthRefusedException, "the key was never refused: ${e.message}") + assertFalse(e.message!!.contains("Exhausted"), "says what stopped it, not sshj's wrapper: ${e.message}") + assertNull(pin.value, "nothing authenticated, nothing pinned") + val first = assertFailsWith { ManualHost.connectFirst(address, identity, connectTimeoutMs = 5_000) } + assertTrue(first.message!!.endsWith(ManualHost.UNREACHABLE_ADVICE), first.message) + assertFalse(first.message!!.contains("authorized_keys"), first.message) + } finally { + sshd.stop(true) + root.deleteRecursively() + } + } + + // ---- no relay, anywhere ------------------------------------------------------------------------- + + @Test + fun `a computer added by address has no relay leg, and every relay verb says remote access isn't set up`() { + assertEquals(RelayLeg.ADDED_OVER_SSH, LegRouting.relayLeg(relayConfigured = false, sshOnlyRoute = true, addedOverSsh = true)) + // Whatever else the phone holds (a token left over, a route set before), it is not a relay leg. + assertEquals(RelayLeg.ADDED_OVER_SSH, LegRouting.relayLeg(relayConfigured = true, sshOnlyRoute = false, addedOverSsh = true)) + assertEquals(RelayLeg.NOT_SET_UP, LegRouting.relayLeg(relayConfigured = false, sshOnlyRoute = false)) + val ssh = HostCapabilities(boardWrites = false, git = false, nodeActions = false, registerNode = false, answerApprovals = true) + for (cap in listOf(Capability.BOARD_WRITES, Capability.REGISTER_NODE, Capability.NODE_ACTIONS, Capability.GIT)) { + for (primary in listOf(TransportKind.SSH, null)) { + val leg = assertIs(LegRouting.route(cap, primary, ssh.takeIf { primary != null }, RelayLeg.ADDED_OVER_SSH), "$cap") + assertTrue(leg.reason.contains("remote access isn't set up for this computer"), leg.reason) + // Not the paired computer's advice: there is no Settings → Phone to turn it on with. + assertFalse(leg.reason.contains("Settings"), leg.reason) + } + } + // What the machine does itself stays on SSH. + assertEquals(Leg.Primary, LegRouting.route(Capability.ANSWER_APPROVALS, TransportKind.SSH, ssh, RelayLeg.ADDED_OVER_SSH)) + } + + @Test + fun `a refusal with no relay to offer says what is in the way for the leg the phone has, and promises no relay`() { + val e = NeedsRelayException("n1", "It opens through the relay.", fact = "It runs elsewhere.") + assertNull(e.refusal(RelayLeg.AVAILABLE), "a usable relay leg is offered instead") + assertEquals("It runs elsewhere. ${NeedsRelayException.NO_RELAY_TO_OFFER}", e.withoutRelay) + assertEquals(e.withoutRelay, e.refusal(RelayLeg.NOT_SET_UP)) + assertEquals(e.withoutRelay, e.refusal(RelayLeg.ADDED_OVER_SSH)) + assertTrue(NeedsRelayException.NO_RELAY_TO_OFFER.startsWith("Remote access isn't set up for this computer")) + // The review of A27b: a paired computer with a relay leg, set to "Only on my network", is told + // which setting is in the way, the way LegRouting's own reason for that leg says it. + assertEquals( + "It runs elsewhere. This computer is set to \"Only on my network (SSH)\": choose \"Automatic\" or \"Only " + + "through the relay\" in Settings → How to reach each computer, or open it in nodeterm on the computer.", + e.refusal(RelayLeg.ROUTE_SSH_ONLY) + ) + assertTrue(e.refusal(RelayLeg.REMOTE_ACCESS_OFF)!!.contains("Remote access is off on the computer right now")) + assertTrue(e.refusal(RelayLeg.NOT_PICKED_UP)!!.contains("tap Refresh")) + // What a session that is not running asks of the user is to START it there. + assertTrue(NeedsRelayException("n1", "x", fact = "Not running.", action = "start it").withoutRelay.endsWith("so start it in nodeterm on the computer.")) + } + + @Test + fun `failures of a computer added by address stop on SSH and are said without your network`() { + val down = HostException("Couldn't connect over SSH to dev@box:22 (timeout).") + assertEquals(SshFallback.Next.Stop("Couldn't connect over SSH to dev@box:22 (timeout)."), SshFallback.afterFailure(down, false, false, addedOverSsh = true)) + // Even if a route allowing the relay were somehow stored for it. + assertIs(SshFallback.afterFailure(down, relayAllowed = true, relayConfigured = true, addedOverSsh = true)) + } + + // ---- the app's wiring (only type-checked here, so pinned in its source) ------------------------ + + @Test + fun `the app keeps a computer added by address on SSH, adopts no relay for it and offers it none`() { + val store = AppSourcePins.app("data/HostStore.kt") + // Its route is SSH whatever is stored, and the stored route says so too for an older build. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(store, "fun route(id: String)"), + "if (get(id)?.manual == true) return RoutePreference.SSH_ONLY" + ) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(store, "fun addManual(host: PairedHost)"), + "ManualHost.existing(", "?.let { return it }", + "save(_hosts.value + host)", + "putString(\"route.\${host.id}\", RoutePreference.SSH_ONLY.name)" + ) + val session = AppSourcePins.app("conn/ConnectionManager.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(session, "private suspend fun adoptRelayIfAdvertised("), + "if (host.manual) return", + "ssh.readRelayAdvertisement()" + ) + AppSourcePins.assertInOrder(session, "private fun relayConfigured(host: PairedHost): Boolean =", "!host.manual &&") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(session, "fun relayLeg()"), "addedOverSsh = host.manual") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(session, "private suspend fun connectLocked("), + "SshFallback.afterFailure(", "addedOverSsh = host.manual" + ) + // A08/A09 refusals with no relay leg to open say what is in the way for THAT leg (the review of + // A27b: by `hasRelay` alone, "Only on my network" was told remote access isn't set up), never + // the relay offer's text; the leg is read once, so the offer and the refusal cannot disagree. + AppSourcePins.assertInOrder( + AppSourcePins.ui("TerminalController.kt"), + "catch (e: NeedsRelayException)", + "state = e.refusal(session.relayLeg())?.let { TermState.Ended(it) } ?: TermState.RelayOffer(msg)" + ) + for (file in listOf("SessionsTab.kt", "InboxTab.kt")) { + AppSourcePins.assertInOrder( + AppSourcePins.ui(file), + "catch (e: NeedsRelayException)", + "e.refusal(session.relayLeg())?.let { throw HostException(it) }", + "session.viaRelay()" + ) + } + for (file in listOf("TerminalController.kt", "SessionsTab.kt", "InboxTab.kt")) { + assertFalse(AppSourcePins.ui(file).contains("withoutRelay"), "$file chooses by the relay leg") + } + assertFalse(session.contains("val hasRelay"), "one way to ask: relayLeg()") + // "Nothing found" offers the relay only to a phone that has one to offer. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(session, "suspend fun refreshNow("), + "c.listProjects()", + "_lastError.value = (e as? NothingFoundException)?.said(relayLeg()) ?: e.message" + ) + AppSourcePins.assertInOrder( + AppSourcePins.ui("InboxTab.kt"), + "(e as? NothingFoundException)?.said(session.relayLeg()) ?: e.message" + ) + } + + @Test + fun `the Hosts screen reaches SSH setup and shows public key and fingerprint instructions`() { + val hosts = AppSourcePins.ui("HostsScreen.kt") + assertTrue(hosts.split("nav.push(Route.AddSshHost)").size - 1 >= 2, "both the empty list and the list offer it") + val forget = AppSourcePins.blockAfter(hosts, "removing?.let { host ->") + AppSourcePins.assertInOrder(forget, "if (host.manual)", "ManualHost.revokeHint(host.user)", "graph.connections.retireAndPublish(listOf(host.id))", "graph.hosts.remove(host.id)") + val main = AppSourcePins.app("MainActivity.kt") + AppSourcePins.assertInOrder(main, "Route.AddSshHost -> listOf(\"addssh\")", "\"addssh\" -> Route.AddSshHost", "Route.AddSshHost -> AddSshHostScreen(nav)") + val screen = AppSourcePins.ui("AddSshHostScreen.kt") + // Connection/authentication behavior is exercised against SSH servers above and in + // SshPasswordBootstrapTest. Do not pin that behavior to inline UI source ordering: + // both setup paths now share checkedAddress(), and source presence proves no execution. + AppSourcePins.assertInOrder(screen, "ManualHost.authorizedKeysLine(graph.sshIdentity)", "ManualHost.installCommand(keyLine)") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(screen, "private fun Added("), "host.sshHostKeyFingerprint", "ManualHost.FINGERPRINT_CHECK_COMMAND") + } + + @Test + fun `the revoke hint and the fingerprint check name what the user looks for on the computer`() { + assertTrue(ManualHost.revokeHint("ops").contains("${ManualHost.KEY_COMMENT} from ~/.ssh/authorized_keys of ops")) + assertTrue(ManualHost.FINGERPRINT_CHECK_COMMAND.contains("ssh-keygen -lf")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt new file mode 100644 index 0000000000..3dd6cbdbb5 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ModelTest.kt @@ -0,0 +1,254 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.AccountNames +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.ManagedAccount +import dev.nodeterm.protocol.model.MirrorSettings +import dev.nodeterm.protocol.model.MirrorUsage +import dev.nodeterm.protocol.model.NodeInfo +import dev.nodeterm.protocol.model.NodeKind +import dev.nodeterm.protocol.model.ObservedAccount +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.SessionBucket +import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.model.UsageAccount +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.relay.Framing +import dev.nodeterm.protocol.relay.Op +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ModelTest { + @Test + fun `a hostile or broken blob degrades section by section, never throws`() { + val snap = ProjectsParser.parseBlob("not json\n--NT-PROJECTS-SPLIT--\nnt-a\njunk\n--NT-STATUS-SPLIT--\n{\"nodes\":{\"a\":{\"state\":42,\"hibernated\":true}}}") + assertTrue(snap.projects.isEmpty()) + assertEquals(setOf("nt-a"), snap.liveSessions) + val st = snap.statusOf("a")!! + assertNull(st.state, "a wrong-typed state is absent, not a crash") + assertEquals(SessionBucket.SLEEPING, st.bucket) + assertTrue(ProjectsParser.parseBlob("").projects.isEmpty()) + } + + @Test + fun `nodes default to terminal and the legacy claude tag still means claude`() { + val snap = ProjectsParser.parseBlob("""{"version":2,"projects":[{"id":"p","name":"P","nodes":[{"id":"n1","title":"t","tags":["claude"]},{"id":"n2","kind":"group"}],"kanban":{"columns":"garbage"}}]}""") + val p = snap.projects.single() + assertEquals(listOf("n1"), p.sessions.map { it.id }) + assertEquals("claude", p.sessions.single().agentId) + assertNotNull(p.board, "columns present but malformed → an empty board, read tolerantly") + } + + @Test + fun `tmux names match tmux-naming ts`() { + assertEquals("nt-term-abc-1", TmuxNames.sessionName("term-abc-1")) + assertEquals("nt-a_b_c", TmuxNames.sessionName("a/b c")) + assertTrue(TmuxNames.isSessionName("nt-a_b")) + } + + @Test + fun `frames round trip with the stable header`() { + val f = Framing.decode(Framing.encode(Op.INPUT, 7, 0x1_0000_0002L, "hi".toByteArray()))!! + assertEquals(Op.INPUT, f.op) + assertEquals(7, f.streamId) + assertEquals(0x1_0000_0002L, f.seq) + assertEquals("hi", String(f.payload)) + assertNull(Framing.decode(ByteArray(15))) + assertNull(Framing.decode(Framing.encode(99, 1, 0, ByteArray(0))), "unknown opcode") + assertEquals(120 to 40, Framing.readSize(Framing.sizePayload(120, 40))) + } + + @Test + fun `pairing payloads parse like pairing-core builds them, and refuse plaintext relays`() { + val p = PairingPayload.parse("""{"v":1,"host":"192.168.1.5","port":22,"user":"me","token":"t","pairPort":5555,"nodeterm":true,"name":"Mac","hostKey":"${"A".repeat(43)}=","relay":{"hostId":"h","hostPublicKeyB64":"${"A".repeat(43)}=","relayEndpoint":"ws://evil.example"},"ssh":false}""")!! + assertEquals("Mac", p.name) + assertEquals(false, p.sshAvailable) + assertNull(p.relay, "ws:// to a non-loopback host is refused (pairing.ts R5)") + assertNull(PairingPayload.parse("""{"v":2,"host":"x"}""")) + assertNull(PairingPayload.parse("nodeterm://pair?code=abc"), "not a payload") + } + + @Test + fun `the URL and bare-code envelopes of pair-qr ts decode to the same payload`() { + val json = """{"v":1,"host":"10.0.0.2","user":"u","token":"t","pairPort":1,"nodeterm":true,"name":"Box é"}""" + val code = dev.nodeterm.protocol.crypto.B64.encodeUrl(json.toByteArray()) + val viaUrl = assertNotNull(PairingPayload.parse("nodeterm://pair?code=$code")) + assertEquals("Box é", viaUrl.name) + assertEquals(viaUrl, PairingPayload.parse(code)) + assertEquals(viaUrl, PairingPayload.parse(json)) + // A desktop-peer relay offer uses the same envelope with a different payload: refused. + val offer = dev.nodeterm.protocol.crypto.B64.encodeUrl("""{"relayEndpoint":"wss://r","pairingToken":"x","hostPublicKeyB64":"y"}""".toByteArray()) + assertNull(PairingPayload.parse("nodeterm://pair?code=$offer")) + assertNull(PairingPayload.parse("https://pair?code=$code")) + } + + @Test + fun `paired host survives a JSON round trip`() { + val p = PairingPayload.parse("""{"v":1,"host":"10.0.0.2","user":"u","token":"t","pairPort":1,"nodeterm":true,"name":"Box"}""")!! + val h = PairedHost.from(p, dev.nodeterm.protocol.pairing.PairingResult("dev-1", "tok", null, null), now = 5) + assertEquals(h, PairedHost.fromJson(h.toJson())) + } + + @Test + fun `resume commands follow each agent's grammar and refuse unsafe ids`() { + assertEquals("claude --resume abc-1", Launch.resumeCommand(Agent.CLAUDE, "abc-1")) + assertEquals("codex resume abc", Launch.resumeCommand(Agent.CODEX, "abc")) + assertEquals("copilot --resume=abc", Launch.resumeCommand(Agent.COPILOT, "abc")) + assertEquals("opencode --session abc", Launch.resumeCommand(Agent.OPENCODE, "abc")) + assertEquals(Agent.ANTIGRAVITY, Agent.of("antigravity")) + assertEquals("Antigravity", Agent.ANTIGRAVITY.label) + assertEquals("#00a3a3", Agent.ANTIGRAVITY.color) + assertEquals("agy --conversation=abc", Launch.resumeCommand(Agent.ANTIGRAVITY, "abc")) + assertNull(Launch.resumeCommand(Agent.ANTIGRAVITY, "abc; echo unsafe")) + assertNull(Launch.resumeCommand(Agent.CLAUDE, "abc; rm -rf ~")) + assertNull(Launch.resumeCommand(Agent.CLAUDE, "-flag")) + } + + @Test + fun `launch emits claude's permission mode only as the desktop would`() { + val s = MirrorSettings("auto", autoSupported = false, claudeAccounts = listOf(ManagedAccount("a1", "/Users/me/Library/Application Support/nodeterm/claude-accounts/a1")), codexApprovalValues = emptyList()) + assertEquals("claude", Launch.launchCommand(Agent.CLAUDE, s, null, null), "auto on an old claude degrades to the bare command") + assertEquals("claude --permission-mode auto", Launch.launchCommand(Agent.CLAUDE, s.copy(autoSupported = true), null, null)) + assertEquals("claude", Launch.launchCommand(Agent.CLAUDE, s.copy(claudePermissionMode = "constructor"), null, null)) + assertEquals( + "cd '/w/p' && CLAUDE_CONFIG_DIR='/Users/me/Library/Application Support/nodeterm/claude-accounts/a1' claude --permission-mode plan", + Launch.launchCommand(Agent.CLAUDE, s.copy(claudePermissionMode = "plan"), "a1", "/w/p") + ) + assertEquals("codex", Launch.launchCommand(Agent.CODEX, s.copy(claudePermissionMode = "plan"), "a1", null)) + assertEquals("gemini", Launch.launchCommand(Agent.GEMINI, s, null, "/it's/unsafe"), "a quote-bearing cwd is dropped, not escaped") + assertTrue(Regex("^term-[a-z0-9]+-[a-z0-9]{1,16}$").matches(Launch.newNodeId())) + } + + private val acctSettings = MirrorSettings("acceptEdits", true, listOf(ManagedAccount("acct-1", "/Users/me/Library/Application Support/node-terminal/claude-accounts/acct-1")), emptyList()) + + @Test + fun `the project's own permission mode wins over the global one, both re-validated (A16)`() { + assertEquals("plan", Launch.permissionMode(acctSettings, "plan")) + assertEquals("acceptEdits", Launch.permissionMode(acctSettings, null)) + assertEquals("acceptEdits", Launch.permissionMode(acctSettings, "constructor"), "an unknown project value falls through") + assertEquals("manual", Launch.permissionMode(null, null)) + assertEquals("claude --permission-mode plan", Launch.launchCommand(Agent.CLAUDE, acctSettings, null, null, "plan")) + // manual in the project = the bare command, even when the global is looser. + assertEquals("claude", Launch.launchCommand(Agent.CLAUDE, acctSettings, null, null, "manual")) + assertEquals("codex", Launch.launchCommand(Agent.CODEX, acctSettings, null, null, "plan"), "Codex cannot express plan") + } + + @Test + fun `the project's default account is preselected only while the host still has it (A16)`() { + val p = ProjectInfo("p", "P", null, "/repo", null, false, emptyList(), null, defaultAccountId = "acct-1") + assertEquals("acct-1", Launch.defaultAccount(acctSettings, p)) + assertNull(Launch.defaultAccount(acctSettings, p.copy(defaultAccountId = "gone"))) + assertNull(Launch.defaultAccount(null, p)) + } + + @Test + fun `the resume line runs where the desktop's cold restore would (A15)`() { + assertEquals( + "cd '/repo/sub' && CLAUDE_CONFIG_DIR='/Users/me/Library/Application Support/node-terminal/claude-accounts/acct-1' " + + "claude --resume abc-1 --permission-mode plan", + Launch.resumeLine(Agent.CLAUDE, "abc-1", acctSettings, "acct-1", "/repo/sub", "plan") + ) + assertEquals("cd '/repo' && codex resume abc", Launch.resumeLine(Agent.CODEX, "abc", acctSettings, "acct-1", "/repo")) + assertNull(Launch.resumeLine(Agent.CLAUDE, "abc; rm -rf ~", acctSettings, null, "/repo")) + } + + @Test + fun `portable node cwds resolve against the project folder`() { + val p = ProjectInfo("p", "P", null, "/repo/", null, false, emptyList(), null) + fun n(cwd: String?) = NodeInfo("n", NodeKind.TERMINAL, "t", null, null, null, cwd, null, null, null, null) + assertEquals("/repo/sub", p.absoluteCwdOf(n("./sub"))) + assertEquals("/repo", p.absoluteCwdOf(n("."))) + assertEquals("/abs", p.absoluteCwdOf(n("/abs"))) + assertEquals("/repo/", p.absoluteCwdOf(n(null))) + assertNull(p.copy(cwd = null).absoluteCwdOf(n("./sub"))) + } + + // ---- account names (A39, A75) ------------------------------------------------------------- + + private val uuid = "3f2a9c1e-5b7d-4e8a-9c0f-1a2b3c4d5e6f" + private val linked = "7d1e0b2a-9f8c-4d3e-8a1b-0c9d8e7f6a5b" + + /** A mirror as a current desktop writes it: settings entries built by `mirrorClaudeAccount` + * (label + email), a usage block, and nodes observed on each kind of account. */ + private val accountsBlob = """ + --NT-STATUS-SPLIT-- + {"v":1,"updatedAt":1,"nodes":{ + "managed":{"agentId":"claude","updatedAt":1,"account":{"configDir":"/data/claude-accounts/$uuid","accountId":"$uuid","known":true}}, + "linked":{"agentId":"claude","updatedAt":1,"account":{"configDir":"/home/me/.claude-2","accountId":"$linked","known":true}}, + "unlinked":{"agentId":"claude","updatedAt":1,"account":{"configDir":"/home/me/.claude-work/","accountId":null,"known":false}}, + "system":{"agentId":"claude","updatedAt":1,"account":{"configDir":"/home/me/.claude","accountId":null,"known":true}}, + "none":{"agentId":"claude","updatedAt":1} + }, + "settings":{"claudeAccounts":[ + {"id":"$uuid","dir":"/data/claude-accounts/$uuid","label":"Work","email":"me@work.example"}, + {"id":"$linked","dir":"/home/me/.claude-2","email":"side@example.com"} + ]}, + "usage":{"updatedAt":1,"accounts":[{"accountId":null,"label":null,"email":"me@home.example","agentId":"claude","status":"ok","updatedAt":1,"limits":[]}]}} + """.trimIndent() + + @Test + fun `the mirror's account label, email and observed account are parsed`() { + val status = ProjectsParser.parseBlob(accountsBlob).status!! + assertEquals(ManagedAccount(uuid, "/data/claude-accounts/$uuid", "Work", "me@work.example"), status.settings!!.claudeAccounts[0]) + assertEquals(ManagedAccount(linked, "/home/me/.claude-2", null, "side@example.com"), status.settings!!.claudeAccounts[1]) + assertEquals(ObservedAccount("/data/claude-accounts/$uuid", uuid, true), status.nodes["managed"]!!.account) + assertEquals(ObservedAccount("/home/me/.claude-work/", null, false), status.nodes["unlinked"]!!.account) + assertNull(status.nodes["none"]!!.account) + // A desktop older than the label keeps parsing: the entry is still a launchable account. + val old = ProjectsParser.parseStatus("""{"settings":{"claudeAccounts":[{"id":"a","dir":"/d","label":7}]}}""")!! + assertEquals(ManagedAccount("a", "/d"), old.settings!!.claudeAccounts.single()) + } + + @Test + fun `a session row names the account it was observed on, never by its UUID (A39)`() { + val status = ProjectsParser.parseBlob(accountsBlob).status!! + fun row(node: String) = AccountNames.observed(status.nodes[node]!!.account, status) + assertEquals("Work", row("managed")) + assertEquals("side@example.com", row("linked"), "no label: the email") + assertEquals(".claude-work", row("unlinked"), "a dir with no record: its last segment") + assertNull(row("system"), "the system account is the unremarkable case") + assertNull(row("none")) + // An id this mirror cannot name: a linked account's dir says more than its id; a managed + // account's dir is only its id again, so the short form. + val bare = status.copy(settings = null) + assertEquals(".claude-2", AccountNames.observed(bare.nodes["linked"]!!.account, bare)) + assertEquals("Account 3f2a9c1e", AccountNames.observed(bare.nodes["managed"]!!.account, bare)) + for (n in status.nodes.keys) assertTrue(AccountNames.observed(status.nodes[n]!!.account, bare)?.contains(uuid) != true, n) + } + + @Test + fun `the new session picker names accounts label, then usage label, then email, then a short id (A75)`() { + val status = ProjectsParser.parseBlob(accountsBlob).status!! + assertEquals("Work", AccountNames.managed(uuid, status)) + assertEquals("side@example.com", AccountNames.managed(linked, status)) + // A desktop older than the settings label: the usage block's label still names it. + val usageOnly = status.copy( + settings = MirrorSettings(null, null, listOf(ManagedAccount(uuid, "/d")), emptyList()), + usage = MirrorUsage(1, listOf(UsageAccount(uuid, "From usage", "u@example.com", "claude", "ok", 1, emptyList()))) + ) + assertEquals("From usage", AccountNames.managed(uuid, usageOnly)) + assertEquals( + "u@example.com", + AccountNames.managed(uuid, usageOnly.copy(usage = MirrorUsage(1, listOf(UsageAccount(uuid, " ", "u@example.com", "claude", "ok", 1, emptyList()))))), + "a blank label is absent" + ) + assertEquals("Account 3f2a9c1e", AccountNames.managed(uuid, null)) + assertEquals("Account acct-1", AccountNames.managed("acct-1", null), "a short id is shown whole") + } + + @Test + fun `a config dir is named by the separator its shape implies`() { + assertEquals(".claude-2", AccountNames.configDirLabel("/home/me/.claude-2/")) + assertEquals("a\\b", AccountNames.configDirLabel("/home/me/a\\b"), "a POSIX backslash is filename text") + assertEquals(".claude-2", AccountNames.configDirLabel("C:\\Users\\me\\.claude-2")) + assertEquals(".claude-2", AccountNames.configDirLabel("C:/Users/me/.claude-2")) + assertEquals("/", AccountNames.configDirLabel("/")) + assertEquals("", AccountNames.configDirLabel(" ")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NewSessionChoiceTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NewSessionChoiceTest.kt new file mode 100644 index 0000000000..7b6411daea --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NewSessionChoiceTest.kt @@ -0,0 +1,143 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.ManagedAccount +import dev.nodeterm.protocol.model.MirrorSettings +import dev.nodeterm.protocol.model.NewSessionChoice +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A42: the New-session dialog remembered the user's taps while the host listing was re-fetched under + * it (every 8 s). Start looked the remembered project up with `first {}`, so a project closed or + * removed on the desktop meanwhile threw NoSuchElementException inside the click handler; and a + * removed account was still sent to the desktop under a picker showing no row selected. + * + * The selection rules are pure and tested here against listings that change between taps. The + * dialog's wiring (it draws and starts with the same derived answer, and never looks a pick up with + * `first {}`) cannot run on a JVM, so it is pinned in the app's source ([AppSourcePins]); nothing + * here has run on a device. + */ +class NewSessionChoiceTest { + private fun project(id: String, cwd: String? = "/w/$id", ssh: String? = null, closed: Boolean = false, defaultAccount: String? = null) = + ProjectInfo(id, id.uppercase(), null, cwd, ssh, closed, emptyList(), null, defaultAccountId = defaultAccount) + + private fun listing(vararg projects: ProjectInfo) = ProjectsSnapshot(projects.toList(), emptySet(), null, 0) + + private fun settings(vararg accountIds: String) = MirrorSettings( + "manual", + false, + accountIds.map { ManagedAccount(it, "/Users/me/Library/Application Support/nodeterm/claude-accounts/$it") }, + emptyList() + ) + + @Test + fun `only open projects on this computer with a folder are offered (A14)`() { + val offered = NewSessionChoice.offeredProjects( + listing( + project("a"), + project("closed", closed = true), + project("remote", ssh = "me@box"), + project("inline", cwd = null), + // A27: run here over SSH by another desktop, whose canvas this computer cannot register in. + project("driven").copy(drivenRemotely = true), + project("b") + ) + ) + assertEquals(listOf("a", "b"), offered.map { it.id }) + } + + @Test + fun `the picked project is used while it is still offered`() { + val offered = NewSessionChoice.offeredProjects(listing(project("a"), project("b"))) + assertEquals("b", NewSessionChoice.project(offered, "b")?.id) + assertEquals("a", NewSessionChoice.project(offered, "a")?.id) + assertEquals("a", NewSessionChoice.project(offered, null)?.id, "no pick yet: the first project, as preselected") + } + + @Test + fun `a picked project that disappears from the listing falls back to one that is shown, instead of crashing Start`() { + // The user picks B; the next re-list no longer has it (closed on the desktop). + val picked = "b" + val relisted = NewSessionChoice.offeredProjects(listing(project("a"), project("b", closed = true), project("c"))) + val selected = NewSessionChoice.project(relisted, picked) + // Start and the radio use this one answer: a project that is on screen, never the hidden B. + assertEquals("a", selected?.id) + assertTrue(selected in relisted) + // Removed outright, or turned into an SSH or cwd-less project: the same. + assertEquals("a", NewSessionChoice.project(NewSessionChoice.offeredProjects(listing(project("a"))), picked)?.id) + assertEquals("a", NewSessionChoice.project(NewSessionChoice.offeredProjects(listing(project("a"), project("b", cwd = null))), picked)?.id) + // It comes back: the user's pick is honoured again. + assertEquals("b", NewSessionChoice.project(NewSessionChoice.offeredProjects(listing(project("a"), project("b"))), picked)?.id) + } + + @Test + fun `with no project left to start in there is no selection, so Start is off`() { + assertNull(NewSessionChoice.project(emptyList(), "b")) + assertNull(NewSessionChoice.project(emptyList(), null)) + assertNull(NewSessionChoice.project(NewSessionChoice.offeredProjects(listing(project("b", closed = true))), "b")) + } + + @Test + fun `the picked account is used while the host still has it, and System is always there`() { + val p = project("a", defaultAccount = "acct-1") + val s = settings("acct-1", "acct-2") + assertEquals("acct-2", NewSessionChoice.account(s, p, "acct-2")) + assertEquals("acct-1", NewSessionChoice.account(s, p, "acct-1")) + assertNull(NewSessionChoice.account(s, p, null), "System was picked") + } + + @Test + fun `a picked account the desktop removed falls back to the project's default, then to System`() { + val p = project("a", defaultAccount = "acct-1") + // acct-2 was picked, then removed on the desktop: the project default still exists. + assertEquals("acct-1", NewSessionChoice.account(settings("acct-1"), p, "acct-2")) + // Both gone (or every account gone, when the picker is not even drawn): System, never a stale id. + assertNull(NewSessionChoice.account(settings("acct-3"), p, "acct-2")) + assertNull(NewSessionChoice.account(settings(), p, "acct-1")) + assertNull(NewSessionChoice.account(null, p, "acct-1")) + // No project selected at all: nothing to take a default from. + assertNull(NewSessionChoice.account(settings("acct-1"), null, "gone")) + } + + private val dialog get() = AppSourcePins.blockAfter(AppSourcePins.ui("SessionsTab.kt"), "fun NewSessionDialog(") + + @Test + fun `the dialog draws and starts with the selection derived from the current listing`() { + val src = dialog + assertTrue(src.contains("val projects = NewSessionChoice.offeredProjects(snapshot)"), "the dialog filters projects on its own") + assertTrue(src.contains("val selected = NewSessionChoice.project(projects, projectId)"), "the selection is not derived from the listing") + // No lookup that can throw on a vanished pick. + assertFalse(src.contains(".first {"), "a remembered pick is looked up with first {}") + assertFalse(src.contains("!!"), "a remembered pick is asserted non-null") + // The radio shown as selected is the one Start uses. + assertTrue(src.contains("RadioButton(selected = selected?.id == p.id, onClick = { projectId = p.id })"), "the project radio is not drawn from the selection") + AppSourcePins.assertInOrder( + src, + "TextButton(enabled = selected != null", + "val p = selected ?: return@start", + "projectId = p.id" + ) + assertTrue(src.contains("if (projects.isEmpty())"), "an empty project list is not explained") + } + + @Test + fun `the account picker draws and starts with the same derived account`() { + val src = dialog + assertTrue(src.contains("remember(selected?.id)"), "the account pick does not follow the selected project") + assertTrue(src.contains("val accountId = NewSessionChoice.account(settings, selected, accountPick)"), "the account is not derived from the listing") + assertTrue(src.contains("RadioButton(selected = accountId == id, onClick = { accountPick = id })"), "the account radio is not drawn from the derived account") + // A `val`, so no tap can overwrite it with an id the listing no longer has. + assertTrue(src.contains("val acct = if (a == Agent.CLAUDE) accountId else null"), "Start does not use the derived account") + } + + @Test + fun `the New session button is offered by the same rule as the dialog's list`() { + val host = AppSourcePins.ui("HostScreen.kt") + assertTrue(host.contains("NewSessionChoice.offeredProjects(snapshot).isNotEmpty()"), "HostScreen keeps its own copy of the project rule") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NotificationActionsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NotificationActionsTest.kt new file mode 100644 index 0000000000..c4ef66d872 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/NotificationActionsTest.kt @@ -0,0 +1,722 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.InboxNotificationActions +import dev.nodeterm.protocol.host.InboxNotificationActions.Action +import dev.nodeterm.protocol.host.InboxNotificationActions.Outcome +import dev.nodeterm.protocol.host.InboxNotificationActions.Request +import dev.nodeterm.protocol.host.InboxNotificationActions.Verb +import dev.nodeterm.protocol.host.LabelEditResult +import dev.nodeterm.protocol.host.NeedsRelayException +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.NewSessionHint +import dev.nodeterm.protocol.host.QuickActions +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.AgentNodeStatus +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.MirrorInbox +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonElement +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A25 (the in-app part): an Inbox notification carries the answers the Inbox card offers, and + * its tap opens the session. + * + * Before, a notification had no actions at all and its tap opened only the computer's Inbox. Now an + * approval gets Approve / Deny and a single-select question its options (three at most, Android's + * limit), sent through [QuickActions] with its re-checks, and only over a leg that makes no first + * relay handshake (nobody is at the app to compare the desktop's approval code, audit A05). The + * decision is the pure [InboxNotificationActions], tested here against [QuickActions] itself, so the + * notification cannot offer what the answer path refuses. The app's part (the PendingIntents, the + * receiver, the expedited worker, the tap's route) cannot run on a JVM and is pinned in its source; + * whether the actions show, and work, on a phone is a device check. + */ +class NotificationActionsTest { + private val node = "term-1" + + private fun ev( + kind: InboxKind = InboxKind.APPROVAL, + pendingId: String? = "term-1-1700000000000-42", + agentId: String? = "claude", + options: List = emptyList(), + multiSelect: Boolean = false, + resolved: Boolean = false, + id: String = "e1" + ) = InboxEvent( + id = id, ts = 1_700_000_000_000, nodeId = node, agentId = agentId, sessionId = "s", kind = kind, + title = "Run rm -rf build", detail = "secret detail", interrupted = false, resolved = resolved, + options = options, multiSelect = multiSelect, pendingId = pendingId + ) + + private fun question(vararg options: String, multiSelect: Boolean = false) = + ev(kind = InboxKind.QUESTION, pendingId = null, options = options.toList(), multiSelect = multiSelect) + + private fun snapshot(state: AgentState?, vararg events: InboxEvent) = ProjectsSnapshot( + projects = emptyList(), liveSessions = setOf("nt-$node"), + status = AgentStatusFile( + updatedAt = 1, + nodes = mapOf(node to AgentNodeStatus(state, "claude", "s", null, false, 1, null)), + settings = null, usage = null, + inbox = MirrorInbox(events.toList(), emptyMap()), + server = null + ), + fetchedAt = 1 + ) + + /** + * A connection whose answers are [answer] / [keys] (default: accept and record). [ssh] makes it + * the direct-SSH transport, which sends an SSH-project node to the relay (audit A09). + */ + private class FakeConn( + private val snap: ProjectsSnapshot, + override val kind: TransportKind = TransportKind.RELAY, + private val answer: suspend (InboxEvent, Boolean) -> ApprovalOutcome = { _, _ -> ApprovalOutcome.SENT }, + private val keys: suspend (String) -> Unit = {} + ) : HostConnection { + val answered = mutableListOf>() + val typed = mutableListOf() + + override val capabilities = HostCapabilities( + boardWrites = false, git = false, nodeActions = false, registerNode = false, answerApprovals = true + ) + + override suspend fun listProjects(): ProjectsSnapshot = snap + override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome = + answer(event, allow).also { answered += event.pendingId to allow } + override suspend fun sendKeys(nodeId: String, keys: String) { + keys(keys) + typed += keys + } + + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream = + error("unused") + override suspend fun wake(nodeId: String) { error("unused") } + override suspend fun refresh(nodeId: String) { error("unused") } + override suspend fun rename(nodeId: String, title: String) { error("unused") } + override suspend fun ensureBoard(projectId: String): List? = error("unused") + override suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean = error("unused") + override suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? = error("unused") + override suspend fun registerNode(projectId: String, node: NewNode): Boolean = error("unused") + override suspend fun ackRead(nodeId: String, eventId: String?) { error("unused") } + override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? = error("unused") + override fun setOnChanged(listener: (() -> Unit)?) {} + override fun setOnClosed(listener: ((String?) -> Unit)?) {} + override fun close() {} + } + + private fun plan(event: InboxEvent, showDetails: Boolean = false, quiet: Boolean = true) = + InboxNotificationActions.plan(event, showDetails, quiet) + + private fun verbs(actions: List) = actions.map { it.verb } + + // --- Which actions an event gets -------------------------------------------------------------- + + @Test + fun `a held approval and a claude prompt get Approve and Deny`() { + assertEquals(listOf(Verb.APPROVE, Verb.DENY), verbs(plan(ev()))) + // A held ticket is answered deterministically whatever the agent (QuickActions' ticket path). + assertEquals(listOf(Verb.APPROVE, Verb.DENY), verbs(plan(ev(agentId = "codex")))) + assertEquals(listOf(Verb.APPROVE, Verb.DENY), verbs(plan(ev(pendingId = null)))) + assertEquals(listOf("Approve", "Deny"), plan(ev()).map { it.label }) + } + + @Test + fun `an approval the answer path sends to the session gets Open, not Approve`() { + // No ticket and not claude: only claude's prompt layout is known, so QuickActions opens the + // session. Offering Approve would only end in "answer it in the session". + for (agent in listOf("codex", "gemini", null)) { + assertEquals(listOf(Verb.OPEN), verbs(plan(ev(pendingId = null, agentId = agent))), "agent $agent") + } + } + + @Test + fun `a single-select question gets one action per option, numbered as the picker numbers them`() { + val actions = plan(question("Yes", "No", "Ask me later")) + assertEquals(listOf(Verb.OPTION, Verb.OPTION, Verb.OPTION), verbs(actions)) + assertEquals(listOf(0, 1, 2), actions.map { it.option }) + assertEquals(listOf("Option 1", "Option 2", "Option 3"), actions.map { it.label }) + assertEquals(listOf("Option 1"), plan(question("Only")).map { it.label }) + } + + @Test + fun `a question with more options than Android shows offers Open, never its first three`() { + // A tap would answer a picker whose fourth option the user never saw. + assertEquals(listOf(Verb.OPEN), verbs(plan(question("a", "b", "c", "d")))) + assertEquals(listOf(Verb.OPEN), verbs(plan(question("a", "b", "c", "d", "e", "f", "g", "h", "i")))) + } + + @Test + fun `a multi-select question, or one without options, offers Open`() { + // The multi-select picker's keys are unmeasured (audit A57): the card shows it read-only too. + assertEquals(listOf(Verb.OPEN), verbs(plan(question("a", "b", multiSelect = true)))) + assertEquals(listOf(Verb.OPEN), verbs(plan(question()))) + } + + @Test + fun `a finished turn and a settled card get no actions`() { + assertEquals(emptyList(), plan(ev(kind = InboxKind.DONE, pendingId = null))) + assertEquals(emptyList(), plan(ev(resolved = true))) + assertEquals(emptyList(), plan(question("a", "b").copy(resolved = true))) + } + + @Test + fun `a computer reachable only through a first relay handshake offers Open`() { + // The desktop would show an approval code that nobody at the phone sees (audit A05). + assertEquals(listOf(Verb.OPEN), verbs(plan(ev(), quiet = false))) + assertEquals(listOf(Verb.OPEN), verbs(plan(question("a", "b"), quiet = false))) + assertEquals(emptyList(), plan(ev(kind = InboxKind.DONE, pendingId = null), quiet = false)) + } + + @Test + fun `quiet reach is the SSH leg or a relay that already approved this phone`() { + for (ssh in listOf(false, true)) for (relay in listOf(false, true)) for (approved in listOf(false, true)) { + assertEquals( + ssh || (relay && approved), + InboxNotificationActions.reachableQuietly(ssh, relay, approved), + "ssh=$ssh relay=$relay approved=$approved" + ) + } + } + + @Test + fun `no plan has more than three actions, and the actions of one notification are told apart`() { + val events = listOf( + ev(), ev(pendingId = null), ev(pendingId = null, agentId = "codex"), + question(), question("a"), question("a", "b"), question("a", "b", "c"), question("a", "b", "c", "d"), + question("a", "b", multiSelect = true), ev(kind = InboxKind.DONE, pendingId = null) + ) + for (e in events) for (details in listOf(false, true)) for (quiet in listOf(false, true)) { + val actions = plan(e, details, quiet) + assertTrue(actions.size <= InboxNotificationActions.MAX_ACTIONS, "${actions.size} actions for $e") + assertEquals(actions.size, actions.map { it.key }.toSet().size, "two actions share a PendingIntent: $actions") + } + } + + @Test + fun `with details off no label carries the option's text, and with details on it is cut`() { + val secret = "Deploy to production with the key in /root/.ssh" + val off = plan(question(secret, "No"), showDetails = false) + assertTrue(off.none { "Deploy" in it.label || "No" == it.label }, "a label leaks the option: $off") + val on = plan(question(secret, "No"), showDetails = true) + assertEquals("2. No", on[1].label) + assertTrue(on[0].label.startsWith("1. Deploy to production"), on[0].label) + assertTrue(on[0].label.length <= InboxNotificationActions.MAX_LABEL_CHARS && on[0].label.endsWith("…"), on[0].label) + // Approve and Deny say nothing about the request, either way. + assertEquals(plan(ev(), showDetails = false), plan(ev(), showDetails = true)) + } + + @Test + fun `every answer a notification offers is one QuickActions sends`() = runBlocking { + // The plan follows the answer path's own rules: what it offers on a node that still asks is + // SENT, never "open the session". + val cases = listOf( + ev() to AgentState.WAITING, // a concurrent ticket on a node waiting on a question (A38) + ev(pendingId = null) to AgentState.BLOCKED, + question("a", "b", "c") to AgentState.WAITING + ) + for ((event, state) in cases) { + for (action in plan(event)) { + val outcome = InboxNotificationActions.perform( + action, event, + connect = { FakeConn(snapshot(state, event)) }, + viaRelay = { error("no relay needed") } + ) + assertTrue( + outcome in setOf(Outcome.APPROVED, Outcome.DENIED, Outcome.ANSWERED), + "${action.verb} on $event ended $outcome" + ) + } + } + } + + // --- What the notification says afterwards ------------------------------------------------------ + + @Test + fun `each result of the answer path is what the notification reports`() { + val approve = Action(Verb.APPROVE, "Approve") + val deny = Action(Verb.DENY, "Deny") + val option = Action(Verb.OPTION, "Option 2", option = 1) + assertEquals(Outcome.APPROVED, InboxNotificationActions.outcomeOf(approve, QuickActions.Result.SENT)) + assertEquals(Outcome.DENIED, InboxNotificationActions.outcomeOf(deny, QuickActions.Result.SENT)) + assertEquals(Outcome.ANSWERED, InboxNotificationActions.outcomeOf(option, QuickActions.Result.SENT)) + for (a in listOf(approve, deny, option)) { + assertEquals(Outcome.ALREADY_HANDLED, InboxNotificationActions.outcomeOf(a, QuickActions.Result.ALREADY_HANDLED)) + assertEquals(Outcome.EXPIRED, InboxNotificationActions.outcomeOf(a, QuickActions.Result.EXPIRED)) + assertEquals(Outcome.OPEN_SESSION, InboxNotificationActions.outcomeOf(a, QuickActions.Result.OPEN_SESSION)) + } + } + + @Test + fun `an outcome that leaves something to do stays and says Tap, and a done one goes away`() { + val option = Action(Verb.OPTION, "Option 2", option = 1) + val done = setOf(Outcome.APPROVED, Outcome.DENIED, Outcome.ANSWERED, Outcome.ALREADY_HANDLED) + for (outcome in Outcome.entries) { + val s = InboxNotificationActions.settled(outcome, option, "studio-mac") + if (outcome in done) { + assertEquals(InboxNotificationActions.SETTLED_SHOWN_MS, s.dismissAfterMs, "$outcome") + } else { + assertNull(s.dismissAfterMs, "$outcome would vanish before the user acts on it") + if (outcome != Outcome.NOT_PAIRED) assertTrue("Tap" in s.text, "$outcome: ${s.text}") + } + } + assertEquals("Answered with option 2.", InboxNotificationActions.settled(Outcome.ANSWERED, option, "x").text) + assertEquals("Already handled.", InboxNotificationActions.settled(Outcome.ALREADY_HANDLED, option, "x").text) + assertTrue("studio-mac" in InboxNotificationActions.settled(Outcome.UNREACHABLE, option, "studio-mac").text) + // Only an unconfirmed answer is said to MAYBE have arrived; the others say nothing was sent. + assertTrue(InboxNotificationActions.settled(Outcome.UNCONFIRMED, option, "x").text.startsWith("Couldn't confirm")) + for (o in listOf(Outcome.NOT_APPROVED, Outcome.UNREACHABLE, Outcome.LOCKED, Outcome.NOT_PAIRED)) { + assertTrue(InboxNotificationActions.settled(o, option, "x").text.startsWith("Not sent"), "$o") + } + } + + // --- Running an answer -------------------------------------------------------------------------- + + @Test + fun `Approve and Deny answer the held ticket over the computer's connection`() = runBlocking { + val event = ev() + for ((action, allow) in listOf(Action(Verb.APPROVE, "Approve") to true, Action(Verb.DENY, "Deny") to false)) { + val conn = FakeConn(snapshot(AgentState.BLOCKED, event)) + val outcome = InboxNotificationActions.perform(action, event, connect = { conn }, viaRelay = { error("unused") }) + assertEquals(if (allow) Outcome.APPROVED else Outcome.DENIED, outcome) + assertEquals(listOf>(event.pendingId to allow), conn.answered) + assertEquals(emptyList(), conn.typed) + } + } + + @Test + fun `an option types its digit, and only on a node still waiting on the question`() = runBlocking { + val event = question("a", "b", "c") + val action = plan(event)[2] + val waiting = FakeConn(snapshot(AgentState.WAITING, event)) + assertEquals(Outcome.ANSWERED, InboxNotificationActions.perform(action, event, { waiting }, { error("unused") })) + assertEquals(listOf("3"), waiting.typed) + // A permission prompt is on screen now: the digit would answer it. QuickActions' rule holds. + val blocked = FakeConn(snapshot(AgentState.BLOCKED, event)) + assertEquals(Outcome.ALREADY_HANDLED, InboxNotificationActions.perform(action, event, { blocked }, { error("unused") })) + assertEquals(emptyList(), blocked.typed) + } + + @Test + fun `a settled card is not answered from a notification left in the shade`() = runBlocking { + val event = ev() + val conn = FakeConn(snapshot(AgentState.WAITING, event.copy(resolved = true))) + assertEquals(Outcome.ALREADY_HANDLED, InboxNotificationActions.perform(plan(event)[0], event, { conn }, { error("unused") })) + assertEquals(emptyList(), conn.answered) + } + + @Test + fun `a notification whose card the desktop's feed dropped types nothing into the prompt on screen now`() = runBlocking { + // The review of A25. A notification stays in the shade with its actions for hours; the desktop + // drops an event after 6 h and trims its feed to 50 events, keeping each node's newest + // unresolved ask only. So: e1 answered on the desktop, then dropped; the node blocks on a newer + // prompt e2. The old Approve (its details naming e1's command) must not type `1` into e2. + val old = ev(pendingId = null) + val newer = ev(pendingId = null, id = "e2") + val blocked = FakeConn(snapshot(AgentState.BLOCKED, newer)) + for (action in plan(old)) { + assertEquals(Outcome.OPEN_SESSION, InboxNotificationActions.perform(action, old, { blocked }, { error("unused") }), "${action.verb}") + } + assertEquals(emptyList(), blocked.typed) + // The same for a question's digit into another picker. + val q = question("a", "b") + val waiting = FakeConn(snapshot(AgentState.WAITING, question("c", "d").copy(id = "e2"))) + for (action in plan(q)) { + assertEquals(Outcome.OPEN_SESSION, InboxNotificationActions.perform(action, q, { waiting }, { error("unused") }), "${action.label}") + } + assertEquals(emptyList(), waiting.typed) + } + + @Test + fun `a computer that cannot be reached is reported, and nothing is sent`() = runBlocking { + val event = ev() + var relayDialed = false + val outcome = InboxNotificationActions.perform( + plan(event)[0], event, + connect = { throw HostException("Through the relay: not approved") }, + viaRelay = { relayDialed = true; error("unused") } + ) + assertEquals(Outcome.UNREACHABLE, outcome) + assertFalse(relayDialed) + } + + @Test + fun `a dial that hangs is given up within its budget`() = runBlocking { + val event = ev() + val started = System.nanoTime() + val outcome = InboxNotificationActions.perform( + plan(event)[0], event, + connect = { awaitCancellation() }, + viaRelay = { error("unused") }, + connectBudgetMs = 100 + ) + assertEquals(Outcome.UNREACHABLE, outcome) + assertTrue(System.nanoTime() - started < 5_000_000_000L) + } + + @Test + fun `a node the SSH transport sends to the relay is answered through the relay`() = runBlocking { + // An SSH-project node over direct SSH (audit A09): the SSH transport refuses before sending. + val event = ev(pendingId = null) + val refuse: suspend (String) -> Unit = { throw NeedsRelayException(node, "runs on another host") } + val ssh = FakeConn(snapshot(AgentState.BLOCKED, event), kind = TransportKind.SSH, keys = refuse) + val relay = FakeConn(snapshot(AgentState.BLOCKED, event)) + val outcome = InboxNotificationActions.perform(plan(event)[0], event, connect = { ssh }, viaRelay = { relay }) + assertEquals(Outcome.APPROVED, outcome) + assertEquals(emptyList(), ssh.typed) + assertEquals(listOf("1"), relay.typed) + } + + @Test + fun `when the relay cannot be reached for such a node, nothing is sent`() = runBlocking { + val event = ev(pendingId = null) + val ssh = FakeConn( + snapshot(AgentState.BLOCKED, event), kind = TransportKind.SSH, + keys = { throw NeedsRelayException(node, "runs on another host") } + ) + val outcome = InboxNotificationActions.perform( + plan(event)[0], event, + connect = { ssh }, + viaRelay = { throw HostException("Not checked in the background: not approved") } + ) + assertEquals(Outcome.UNREACHABLE, outcome) + } + + @Test + fun `an answer that fails once connected is unconfirmed, and is not sent again`() = runBlocking { + val event = ev() + var tries = 0 + val conn = FakeConn(snapshot(AgentState.BLOCKED, event), answer = { _, _ -> tries++; throw HostException("socket closed") }) + val outcome = InboxNotificationActions.perform(plan(event)[0], event, { conn }, { error("unused") }) + assertEquals(Outcome.UNCONFIRMED, outcome) + assertEquals(1, tries) + } + + @Test + fun `an answer that hangs is unconfirmed within its budget`() = runBlocking { + val event = ev() + val conn = FakeConn(snapshot(AgentState.BLOCKED, event), answer = { _, _ -> awaitCancellation() }) + val outcome = InboxNotificationActions.perform(plan(event)[0], event, { conn }, { error("unused") }, answerBudgetMs = 100) + assertEquals(Outcome.UNCONFIRMED, outcome) + } + + @Test + fun `Open answers nothing and connects to nothing`() = runBlocking { + val event = ev() + val outcome = InboxNotificationActions.perform( + Action(Verb.OPEN, InboxNotificationActions.OPEN_LABEL), event, + connect = { error("Open dialed the computer") }, + viaRelay = { error("Open dialed the relay") } + ) + assertEquals(Outcome.OPEN_SESSION, outcome) + } + + // --- The work's whole run ---------------------------------------------------------------------- + + /** + * The computer as [InboxNotificationActions.answerOnce] reaches it: [conn] for every dial, which + * must happen while the connection is held. + */ + private class FakeRoute( + private val conn: HostConnection, + override val connected: Boolean = false, + private val quiet: Boolean = true + ) : InboxNotificationActions.QuietRoute { + var holds = 0 + var holding = false + var dials = 0 + var dialedOutsideHold = false + + override fun reachableQuietly(): Boolean = quiet + override suspend fun hold(block: suspend () -> Outcome): Outcome { + holds++ + holding = true + try { + return block() + } finally { + holding = false + } + } + override suspend fun connect(): HostConnection = dial() + override suspend fun viaRelay(): HostConnection = dial() + private fun dial(): HostConnection { + if (!holding) dialedOutsideHold = true + dials++ + return conn + } + } + + @Test + fun `the first run answers while it holds the shared connection`() = runBlocking { + val event = ev() + val conn = FakeConn(snapshot(AgentState.BLOCKED, event)) + val route = FakeRoute(conn) + assertEquals(Outcome.APPROVED, InboxNotificationActions.answerOnce(plan(event)[0], event, runAttempt = 0, route = route)) + assertEquals(listOf>(event.pendingId to true), conn.answered) + assertEquals(1, route.holds) + assertEquals(1, route.dials) + assertFalse(route.dialedOutsideHold, "dialed on a connection another job may close") + } + + @Test + fun `a run WorkManager starts again sends nothing and says the answer is unconfirmed`() = runBlocking { + // The review of A25: WorkManager runs again work that was interrupted rather than finished (a + // system stop, or the process dying mid-run, re-enqueued at the next start). The first run may + // have sent the answer; a second `1` would land in whatever the pane shows by then. + val event = ev(pendingId = null) + for (attempt in listOf(1, 2, 5)) { + val conn = FakeConn(snapshot(AgentState.BLOCKED, event)) + val route = FakeRoute(conn) + assertEquals(Outcome.UNCONFIRMED, InboxNotificationActions.answerOnce(plan(event)[0], event, attempt, route)) + assertEquals(0, route.holds, "attempt $attempt touched the connection") + assertEquals(0, route.dials, "attempt $attempt dialed") + assertEquals(emptyList(), conn.typed) + // Even for a computer forgotten since: the first run may have answered it. + assertEquals(Outcome.UNCONFIRMED, InboxNotificationActions.answerOnce(plan(event)[0], event, attempt, route = null)) + } + } + + @Test + fun `a forgotten computer, or one without quiet reach, is not dialed`() = runBlocking { + val event = ev() + assertEquals(Outcome.NOT_PAIRED, InboxNotificationActions.answerOnce(plan(event)[0], event, 0, route = null)) + val conn = FakeConn(snapshot(AgentState.BLOCKED, event)) + val unapproved = FakeRoute(conn, quiet = false) + assertEquals(Outcome.NOT_APPROVED, InboxNotificationActions.answerOnce(plan(event)[0], event, 0, unapproved)) + assertEquals(0, unapproved.dials) + assertEquals(emptyList(), conn.answered) + // A connection already open makes no handshake: it is reused. + val open = FakeRoute(conn, connected = true, quiet = false) + assertEquals(Outcome.APPROVED, InboxNotificationActions.answerOnce(plan(event)[0], event, 0, open)) + assertFalse(open.dialedOutsideHold) + } + + // --- The tap's hand-off ------------------------------------------------------------------------- + + private fun request(event: InboxEvent = question("Yes", "No"), action: Action = plan(event, showDetails = true)[1]) = Request( + hostId = "host-1", computerName = "studio-mac", + notificationId = InboxNotificationActions.notificationId("host-1", event.id), + headline = "Needs you — build-bot", sessionTitle = "build-bot", action = action, event = event + ) + + @Test + fun `a request survives the trip through the Intent and the work input`() { + for (r in listOf(request(), request(ev(), plan(ev())[1]), request(ev(pendingId = null, agentId = null), Action(Verb.APPROVE, "Approve")))) { + val back = Request.decode(r.encode()) + // Everything QuickActions reads, the notification's words, and the action itself. + assertEquals(r.copy(event = r.event.copy(title = "", detail = null)), back) + } + assertEquals("nodeterm.inbox.answer.host-1.e1", request().workName) + assertEquals("host-1:e1".hashCode(), request().notificationId) + } + + @Test + fun `the request carries no title or detail of the event`() { + // Nothing after the tap needs them, and the extras outlive the notification (audit A52). + val raw = request(ev()).encode() + assertFalse("rm -rf" in raw, raw) + assertFalse("secret detail" in raw, raw) + } + + @Test + fun `anything but an encoded request decodes to null`() { + val good = request().encode() + assertTrue(Request.decode(good) != null) + for (bad in listOf( + null, "", "{", "[]", "{}", + good.replace("\"v\":1", "\"v\":2"), + good.replace("\"verb\":\"option\"", "\"verb\":\"always\""), + good.replace("\"option\":1", "\"option\":9"), + good.replace("\"kind\":\"question\"", "\"kind\":\"other\""), + good.replace("\"hostId\":\"host-1\"", "\"hostId\":\"\""), + good.replace("\"nodeId\":\"term-1\"", "\"nodeId\":7") + )) { + assertNull(Request.decode(bad), "decoded: $bad") + } + } + + // --- The app's wiring, pinned in its source ---------------------------------------------------- + + private val notifier get() = AppSourcePins.app("notify/InboxNotifier.kt") + private val actions get() = AppSourcePins.app("notify/InboxActions.kt") + + /** An expression-bodied function of [source], from [start] to the blank line after it. */ + private fun expressionAfter(source: String, start: String): String { + val at = source.indexOf(start) + assertTrue(at >= 0, "not found: $start") + val end = source.indexOf("\n\n", at) + return if (end < 0) source.substring(at) else source.substring(at, end) + } + + @Test + fun `the notification carries the planned actions, each answer behind an unlock`() { + val build = AppSourcePins.blockAfter(notifier, "private fun build(") + AppSourcePins.assertInOrder( + build, + "for (action in InboxNotificationActions.plan(ev, showDetails, quiet))", + "if (action.answers) answerIntent(context,", + "else open", + "builder.addAction(", + ".setAuthenticationRequired(action.answers)" + ) + assertFalse(build.contains("connections.session("), "Notification publication must not take ConnectionManager's monitor") + assertTrue(notifier.contains("showDetails: Boolean,\n quiet: Boolean")) + // The id the receiver and the worker update is the one the notification was posted under. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(notifier, "fun announce("), + "nm.notify(InboxNotificationActions.notificationId(host.id, ev.id), build(context, host, snapshot, ev, showDetails, quiet))" + ) + val session = AppSourcePins.blockAfter(AppSourcePins.app("conn/ConnectionManager.kt"), "fun reachableQuietly()") + AppSourcePins.assertInOrder( + session, + "InboxNotificationActions.reachableQuietly(", + "sshLeg = host.sshAvailable && graph.hosts.route(hostId) != RoutePreference.RELAY_ONLY", + "relayLeg = relayLeg() == LegRouting.RelayLeg.AVAILABLE", + "relayApproved = graph.hosts.relayApproved(hostId)" + ) + } + + @Test + fun `every PendingIntent is immutable and explicit, and the tap names the session`() { + val open = expressionAfter(notifier, "private fun openSession(") + AppSourcePins.assertInOrder( + open, + "PendingIntent.getActivity(", + // One per notification: the extras differ, and FLAG_UPDATE_CURRENT on a shared request code + // hands every notification of a computer the last one's session. + "notificationId,", + "Intent(context, MainActivity::class.java)", + ".putExtra(MainActivity.EXTRA_HOST_ID, hostId)", + ".putExtra(MainActivity.EXTRA_NODE_ID, nodeId)", + ".putExtra(MainActivity.EXTRA_NODE_TITLE, title)", + "PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE" + ) + val answer = expressionAfter(notifier, "private fun answerIntent(") + AppSourcePins.assertInOrder( + answer, + "PendingIntent.getBroadcast(", + "Intent(context, InboxActionReceiver::class.java)", + ".setData(", + "request.action.key", + ".putExtra(EXTRA_REQUEST, request.encode())", + "PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE" + ) + for (src in listOf(notifier, actions)) { + assertFalse("FLAG_MUTABLE" in src, "a mutable PendingIntent lets another app rewrite the answer") + } + val manifest = File(InteropHarness.repoRoot, "android/app/src/main/AndroidManifest.xml").readText() + assertTrue( + Regex("""""").containsMatchIn(manifest), + "the answer receiver must be declared and not exported" + ) + } + + @Test + fun `a tap hands the answer to expedited work, once, after the lock check`() { + val receive = AppSourcePins.blockAfter(actions, "override fun onReceive(") + AppSourcePins.assertInOrder( + receive, + "Request.decode(intent.getStringExtra(InboxNotifier.EXTRA_REQUEST))", + "if (!request.action.answers) return", + "if (Build.VERSION.SDK_INT < 31", + "isDeviceLocked", + "InboxNotifier.settle(context, request, Outcome.LOCKED)", + "return", + "InboxNotifier.sending(context, request)", + ".setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)", + "enqueueUniqueWork(request.workName, ExistingWorkPolicy.KEEP, work)" + ) + } + + @Test + fun `an answer from a notification never makes a first relay handshake, and is never retried`() { + // The run is answerOnce's (tested above against fakes): its run attempt count, so a run + // WorkManager starts again sends nothing, and the computer as the background dials reach it. + val doWork = AppSourcePins.blockAfter(actions, "override suspend fun doWork()") + AppSourcePins.assertInOrder( + doWork, + "InboxNotificationActions.answerOnce(request.action, request.event, runAttemptCount, session?.let(::quietRoute))", + "InboxNotifier.settle(applicationContext, request, outcome)", + "return Result.success()" + ) + assertFalse("retry" in doWork, "an unconfirmed answer must not be sent again") + val route = AppSourcePins.blockAfter(actions, "private fun quietRoute(session: HostSession)") + AppSourcePins.assertInOrder( + route, + "override val connected: Boolean get() = session.connection != null", + "override fun reachableQuietly(): Boolean = session.reachableQuietly()", + "override suspend fun hold(block: suspend () -> Outcome): Outcome = session.inBackground(block)", + "override suspend fun connect(): HostConnection = session.ensureConnected(RelayApprovalGate.Trigger.BACKGROUND)", + "override suspend fun viaRelay(): HostConnection = session.viaRelay(RelayApprovalGate.Trigger.BACKGROUND)" + ) + // Every dial in the file is the background one; USER would release a hold and show a code. + assertFalse(Regex("""Trigger\.(USER|AUTO)""").containsMatchIn(actions), "a notification action dials as the user at the app") + assertFalse(Regex("""\.(ensureConnected|viaRelay)\(\s*\)""").containsMatchIn(actions), "a dial with the default trigger") + } + + @Test + fun `the background jobs share a computer's connection, and only the last one closes it`() { + // The review of A25: each job closed the shared connection whenever no screen watched, under + // another job still answering on it. The counting is ConnectionUsers' (ConnectionUsersTest). + val session = AppSourcePins.app("conn/ConnectionManager.kt") + assertTrue( + session.contains("suspend fun inBackground(block: suspend () -> T): T = users.hold({ disconnect() }, block)"), + "HostSession.inBackground is not ConnectionUsers.hold" + ) + assertTrue(session.contains("scope, users, { disconnect() },"), "polling and background jobs must share ConnectionUsers") + assertTrue(AppSourcePins.blockAfter(session, "fun startWatching(").contains("foreground.start(initialTrigger)")) + assertTrue(AppSourcePins.blockAfter(session, "fun stopWatching(").contains("foreground.stop(closeWhenUnused)")) + val check = AppSourcePins.blockAfter(notifier, "override suspend fun doWork()") + AppSourcePins.assertInOrder(check, "session.inBackground {", "session.refreshNow(RelayApprovalGate.Trigger.BACKGROUND)") + for ((name, src) in listOf("the answer" to actions, "the background check" to check)) { + assertFalse(".disconnect()" in src, "$name closes the shared connection itself") + } + } + + @Test + fun `a notification tap opens that session's terminal, at launch and while the app runs`() { + val main = AppSourcePins.app("MainActivity.kt") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(main, "private fun Navigator.openTap(tap: NotificationTap)"), + "push(Route.Host(tap.hostId, tab = 2))", + "push(Route.Terminal(tap.hostId, node," + ) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(main, "override fun onNewIntent(intent: Intent)"), + "setIntent(intent)", + "tapOf(intent)?.let { incomingTap = it }" + ) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(main, "override fun onCreate("), + "val launchTap = if (fresh) tapOf(intent) else null", + "if (launchTap != null && graph.hosts.get(launchTap.hostId) != null) n.openTap(launchTap)", + "LaunchedEffect(tap) {", + "nav.replaceAll(Route.Hosts)", + "nav.openTap(tap)" + ) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(main, "private fun tapOf(intent: Intent?)"), + "getStringExtra(EXTRA_HOST_ID)", + "getStringExtra(EXTRA_NODE_ID)", + "getStringExtra(EXTRA_NODE_TITLE)" + ) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/Osc52Test.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/Osc52Test.kt new file mode 100644 index 0000000000..75873ac372 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/Osc52Test.kt @@ -0,0 +1,141 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.Osc52 +import dev.nodeterm.protocol.model.Osc52.Result +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** A53: OSC 52 copy, parsed like the desktop's parseOsc52 but capped for the Android clipboard. */ +class Osc52Test { + private fun b64(bytes: ByteArray): String = Base64.getEncoder().encodeToString(bytes) + private fun b64(text: String): String = b64(text.toByteArray(Charsets.UTF_8)) + private fun bytes(vararg b: Int) = ByteArray(b.size) { b[it].toByte() } + + @Test + fun `a normal payload decodes to its text`() { + assertEquals(Result.Copy("Hello, world"), Osc52.parse("c;" + b64("Hello, world"))) + val multi = "héllo ✓ 你好 😀\nsecond line" + assertEquals(Result.Copy(multi), Osc52.parse("c;" + b64(multi))) + // Any selection name, including none at all, as tmux and vim both send. + assertEquals(Result.Copy("x"), Osc52.parse(";" + b64("x"))) + assertEquals(Result.Copy("x"), Osc52.parse("pc;" + b64("x"))) + } + + @Test + fun `the selection separator is required`() { + // A bare base64 string used to be copied as if it were the payload. + assertEquals(Result.Ignored, Osc52.parse(b64("Hello"))) + assertEquals(Result.Ignored, Osc52.parse("")) + } + + @Test + fun `a selection field longer than any xterm uses is not a clipboard write`() { + val sel = "c".repeat(Osc52.MAX_SELECTION) + assertEquals(Result.Copy("x"), Osc52.parse(sel + ";" + b64("x"))) + assertEquals(Result.Ignored, Osc52.parse(sel + "c;" + b64("x"))) + } + + @Test + fun `a read query and an empty payload copy nothing`() { + assertEquals(Result.Ignored, Osc52.parse("c;?")) + assertEquals(Result.Ignored, Osc52.parse(";?")) + assertEquals(Result.Ignored, Osc52.parse("c;")) + // Whitespace is valid base64 for nothing: no "Copied" over an emptied clipboard. + assertEquals(Result.Ignored, Osc52.parse("c; \n")) + } + + @Test + fun `invalid base64 is refused, not skipped over`() { + // android.util.Base64 skipped the characters it did not know and decoded the rest. + assertEquals(Result.Invalid, Osc52.parse("c;not_base64!!")) + assertEquals(Result.Invalid, Osc52.parse("c;SGVs-bG8=")) + assertEquals(Result.Invalid, Osc52.parse("c;SG=VsbG8=")) // padding in the middle + assertEquals(Result.Invalid, Osc52.parse("c;SGVsbG8===")) // three '=' + assertEquals(Result.Invalid, Osc52.parse("c;A")) // 1 mod 4 + assertEquals(Result.Invalid, Osc52.parse("c;SGVsbG8=A")) + } + + @Test + fun `base64 is as forgiving as atob and no more`() { + assertEquals(Result.Copy("Hello"), Osc52.parse("c;SGVsbG8=")) // padded + assertEquals(Result.Copy("Hello"), Osc52.parse("c;SGVsbG8")) // unpadded + assertEquals(Result.Copy("Hello"), Osc52.parse("c;SGVs\r\n bG8\t=")) // ASCII whitespace + assertEquals(Result.Copy("Hi"), Osc52.parse("c;SGk=")) // one '=' + assertEquals(Result.Copy("H"), Osc52.parse("c;SA==")) // two '=' + // Leftover bits of a short last group are discarded, not checked ("SB" and "SA" both = "H"). + assertEquals(Result.Copy("H"), Osc52.parse("c;SB")) + // Non-ASCII whitespace is not whitespace to atob. + assertEquals(Result.Invalid, Osc52.parse("c;SGVs bG8=")) + } + + @Test + fun `invalid UTF-8 is refused, never replaced`() { + assertEquals(Result.Invalid, Osc52.parse("c;" + b64(bytes(0xC3, 0x28)))) // bad continuation + assertEquals(Result.Invalid, Osc52.parse("c;" + b64(bytes(0x61, 0xE2, 0x82)))) // truncated + assertEquals(Result.Invalid, Osc52.parse("c;" + b64(bytes(0xC0, 0xAF)))) // overlong '/' + assertEquals(Result.Invalid, Osc52.parse("c;" + b64(bytes(0xED, 0xA0, 0x80)))) // a surrogate + assertEquals(Result.Invalid, Osc52.parse("c;" + b64(bytes(0xFF)))) + } + + @Test + fun `one leading byte-order mark is dropped, as TextDecoder drops it`() { + assertEquals(Result.Copy("hi"), Osc52.parse("c;" + b64("hi"))) + assertEquals(Result.Copy("hi"), Osc52.parse("c;" + b64("hi"))) + assertEquals(Result.Ignored, Osc52.parse("c;" + b64(""))) + } + + @Test + fun `the payload the desktop's cap lets through is refused before decoding`() { + // tmux 3.4 forwards a 986,675-character OSC 52 (measured by the audit's verifier): under the + // desktop's 1,000,000, about 1.5 MB as a UTF-16 parcel, over the binder buffer. + assertEquals(Result.TooLarge, Osc52.parse("c;" + "A".repeat(986_675))) + assertEquals(Result.TooLarge, Osc52.parse("c;" + "A".repeat(Osc52.MAX_BASE64 + 1))) + // Even when it is not base64 at all: the length is checked first. + assertEquals(Result.TooLarge, Osc52.parse("c;" + "!".repeat(Osc52.MAX_BASE64 + 1))) + assertEquals(Result.Invalid, Osc52.parse("c;" + "!".repeat(Osc52.MAX_BASE64))) + } + + @Test + fun `the text cap is exact`() { + val atCap = "a".repeat(Osc52.MAX_TEXT_CHARS) + assertEquals(Result.Copy(atCap), Osc52.parse("c;" + b64(atCap))) + // Well under the base64 cap, over the text cap. + val overCap = "a".repeat(Osc52.MAX_TEXT_CHARS + 1) + assertTrue(b64(overCap).length < Osc52.MAX_BASE64) + assertEquals(Result.TooLarge, Osc52.parse("c;" + b64(overCap))) + } + + @Test + fun `the base64 cap never refuses a text the text cap takes`() { + // Three UTF-8 bytes per UTF-16 unit is the worst case, and it lands exactly on the cap. + val cjk = "你".repeat(Osc52.MAX_TEXT_CHARS) + val payload = b64(cjk) + assertEquals(Osc52.MAX_BASE64, payload.length) + assertEquals(Result.Copy(cjk), Osc52.parse("c;$payload")) + // Four-byte characters are two units each: denser in units, so the text cap binds first. + val emoji = "😀".repeat(Osc52.MAX_TEXT_CHARS / 2) + assertEquals(Result.Copy(emoji), Osc52.parse("c;" + b64(emoji))) + assertEquals(Result.TooLarge, Osc52.parse("c;" + b64(emoji + "a"))) + } + + @Test + fun `the clip stays far below the shared binder buffer`() { + // ClipData parcels its text as UTF-16; the system server's buffer is about 1 MB, shared. + assertTrue(Osc52.MAX_TEXT_CHARS * 2 <= 256 * 1024) + } + + @Test + fun `decodeBase64 matches the JDK on well-formed input`() { + for (len in 0..20) { + val data = ByteArray(len) { (it * 37 + 11).toByte() } + val padded = b64(data) + assertContentEquals(data, Osc52.decodeBase64(padded)) + assertContentEquals(data, Osc52.decodeBase64(padded.trimEnd('='))) + } + assertNull(Osc52.decodeBase64("AAAAA")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairedHostProfileTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairedHostProfileTest.kt new file mode 100644 index 0000000000..07b3c1f716 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairedHostProfileTest.kt @@ -0,0 +1,53 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.ssh.SshProfilePath +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** The optional profile lives in the phone's saved host record, not the pairing payload. */ +class PairedHostProfileTest { + private fun host(profile: String? = null) = PairedHost( + id = "toy-host", name = "Toy computer", host = "192.0.2.1", port = 22, user = "toy", + sshAvailable = true, hostKeyB64 = null, relay = null, sshHostKeyFingerprint = "SHA256:toy", + pairedAt = 1, sshProfilePath = profile + ) + + @Test fun `explicit profile survives JSON round trip without changing other public facts`() { + val record = host("/profiles/é space's ${'$'}HOME") + val encoded = record.toJson() + assertEquals(record.sshProfilePath, encoded.getValue("sshProfilePath").jsonPrimitive.content) + assertEquals(record, PairedHost.fromJson(encoded)) + } + + @Test fun `older records and automatic discovery omit the optional field`() { + val record = host() + assertFalse("sshProfilePath" in record.toJson()) + assertEquals(record, PairedHost.fromJson(record.toJson())) + assertNull(PairedHost.fromJson(record.toJson())!!.sshProfilePath) + } + + @Test fun `malformed explicit selections reject the record instead of choosing automatic`() { + for (value in listOf(JsonNull, JsonPrimitive(7), JsonPrimitive(false), + JsonArray(emptyList()), JsonObject(emptyMap()))) { + assertNull(PairedHost.fromJson(JsonObject(host().toJson() + ("sshProfilePath" to value)))) + } + } + + @Test fun `invalid saved string is retained so connect can refuse its original authority`() { + for (path in listOf("", "relative-profile", "/a/../other", "/a\u0085b")) { + val record = assertNotNull(PairedHost.fromJson(host(path).toJson())) + assertEquals(path, record.sshProfilePath) + assertFailsWith { SshProfilePath.requireValid(record.sshProfilePath) } + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairedHostReplacementTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairedHostReplacementTest.kt new file mode 100644 index 0000000000..feb6fe5f9f --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairedHostReplacementTest.kt @@ -0,0 +1,139 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.PairedHostReplacement +import kotlin.test.* +import org.junit.jupiter.api.Test + +class PairedHostReplacementTest { + private fun host(id: String, address: String = "qa-host", user: String = "qa", name: String = "Fixture") = + PairedHost(id, name, address, 22, user, true, "public-host-key", null, null, 1) + + @Test fun `replacement retires old pairing preferences and keeps unrelated computers`() { + val old = host("old"); val other = host("other", address = "other-host"); val incoming = host("new") + val replacement = PairedHostReplacement.plan(listOf(old, other), incoming) + assertEquals(listOf(other, incoming), replacement.hosts) + assertEquals(listOf(old), replacement.replaced) + assertEquals(listOf("route.old", "relayApproved.old", "managedCreation.old"), replacement.retiredPreferenceKeys) + } + + @Test fun `retained pairing id keeps preferences when its public facts update`() { + val old = host("same"); val incoming = old.copy(port = 2222, sshHostKeyFingerprint = "SHA256:new-public-pin", pairedAt = 2) + val replacement = PairedHostReplacement.plan(listOf(old), incoming) + assertEquals(listOf(incoming), replacement.hosts) + assertTrue(replacement.replaced.isEmpty()) + assertTrue(replacement.retiredPreferenceKeys.isEmpty(), "A retained identity still owns its route, approval and saved creation") + } + + @Test fun `retiring duplicate endpoint records never retires the incoming retained id`() { + val retained = host("retained"); val older = host("older"); val oldest = host("oldest") + val replacement = PairedHostReplacement.plan(listOf(retained, older, oldest), retained.copy(pairedAt = 2)) + assertEquals(listOf("retained"), replacement.hosts.map { it.id }) + assertEquals(listOf(older, oldest), replacement.replaced) + assertEquals(listOf("route.older", "relayApproved.older", "managedCreation.older", + "route.oldest", "relayApproved.oldest", "managedCreation.oldest"), replacement.retiredPreferenceKeys) + assertFalse(replacement.retiredPreferenceKeys.any { it.endsWith(".retained") }) + } + + @Test fun `different address user or displayed computer name is not a replacement`() { + val incoming = host("new") + val existing = listOf(host("address", address = "elsewhere"), host("user", user = "another"), host("name", name = "Another profile")) + val replacement = PairedHostReplacement.plan(existing, incoming) + assertEquals(existing + incoming, replacement.hosts) + assertTrue(replacement.replaced.isEmpty()); assertTrue(replacement.retiredPreferenceKeys.isEmpty()) + } + + @Test fun `a changed SSH port preserves the existing endpoint name replacement policy`() { + val old = host("old"); val incoming = host("new").copy(port = 2222) + val replacement = PairedHostReplacement.plan(listOf(old), incoming) + assertEquals(listOf(incoming), replacement.hosts) + assertEquals(listOf(old), replacement.replaced) + } + + @Test fun `duplicate retired records produce only one preference removal per key`() { + val old = host("old") + val replacement = PairedHostReplacement.plan(listOf(old, old), host("new")) + assertEquals(listOf("route.old", "relayApproved.old", "managedCreation.old"), replacement.retiredPreferenceKeys) + assertEquals(listOf("new"), replacement.hosts.map { it.id }) + } + + @Test fun `pairing resolves prior key and endpoint replacements exactly once`() { + val prior = host("prior", address = "old-address") + val endpoint = host("endpoint"); val other = host("other", address = "elsewhere") + assertEquals(listOf(prior, endpoint), PairedHostReplacement.retired(listOf(prior, endpoint, other), host("new"), prior)) + assertEquals(listOf(endpoint), PairedHostReplacement.retired(listOf(endpoint), host("new"), endpoint)) + } + + @Test fun `pairing refreshes the incoming retained connection without retiring its preferences`() { + val retained = host("same"); val old = host("older"); val incoming = retained.copy(pairedAt = 2) + assertEquals(listOf(retained, old), PairedHostReplacement.retired(listOf(retained, old), incoming, retained)) + assertEquals(listOf(old), PairedHostReplacement.plan(listOf(retained, old), incoming).replaced) + assertFalse(PairedHostReplacement.plan(listOf(retained, old), incoming).retiredPreferenceKeys.any { it.endsWith(".same") }) + } + + @Test fun `native store retires preferences in the same edit before publishing new host readers`() { + val source = AppSourcePins.app("data/HostStore.kt") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "fun upsert(host: PairedHost)"), + "PairedHostReplacement.plan(_hosts.value, host)", + "save(replacement.hosts, replacement.retiredPreferenceKeys)", + "for (old in replaced) seenLog.moveHost(old.id, host.id)") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "private fun save(list: List"), + "val edit = prefs.edit()", "for (key in retiredKeys) edit.remove(key)", + "edit.putString(\"hosts\"", ").apply()", "_hosts.value = list") + } + + @Test fun `native pairing disconnects before atomic token and record publication`() { + val pair = AppSourcePins.ui("PairScreen.kt") + AppSourcePins.assertInOrder(pair, + "PairedHostReplacement.retired(graph.hosts.hosts.value, host, previous)", + ".map { it.id } + host.id", "graph.connections.retireAndPublish(retiredIds)", "graph.hosts.publishPairing(host, previous", + "saveToken = { result.relayDeviceToken?.let { graph.secure.putString(SecureStore.relayTokenKey(host.id), it) } }", + "removeToken = { id -> graph.secure.remove(SecureStore.relayTokenKey(id)) }") + val store = AppSourcePins.app("data/HostStore.kt") + assertTrue(store.contains("@Synchronized\n fun publishPairing")) + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(store, "fun publishPairing("), + "PairedHostReplacement.retired(_hosts.value, host, previous)", + "if (old.id != host.id) remove(old.id, successor = host.id) { removeToken(old.id) }", + "saveToken()", "upsert(host)") + val removeSignature = "fun remove(id: String, successor: String? = null, removeToken: () -> Unit = {})" + assertTrue(store.contains(removeSignature)) + val removal = AppSourcePins.blockAfter(store.substringAfter(removeSignature), "") + AppSourcePins.assertInOrder(removal, "removeToken()", "save(_hosts.value.filterNot") + val forget = AppSourcePins.ui("HostsScreen.kt") + AppSourcePins.assertInOrder(forget, "graph.connections.retireAndPublish(listOf(host.id))", + "graph.hosts.remove(host.id) { graph.secure.remove(SecureStore.relayTokenKey(host.id)) }") + } + + @Test fun `native late preference writers cannot restore forgotten host state`() { + val source = AppSourcePins.app("data/HostStore.kt") + assertTrue(source.contains("@Synchronized\n fun setRelayApproved")) + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "fun setRelayApproved("), + "if (get(id) == null) return", "prefs.edit().putBoolean") + assertTrue(source.contains("@Synchronized\n fun setRoute")) + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "fun setRoute("), + "val host = get(id) ?: return", "if (host.manual) return", "prefs.edit().putString") + val storage = AppSourcePins.blockAfter(source, "fun managedCreationStorage(") + val read = AppSourcePins.blockAfter(storage, "override fun read()") + assertTrue(storage.contains("override fun read(): String? = synchronized(this@HostStore)")) + assertTrue(read.contains("if (!current() || get(hostId) == null) null")) + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(storage, "override fun write("), + "synchronized(this@HostStore)", "if (!current() || get(hostId) == null) throw", ".commit()") + } + + @Test fun `late relay adoption publishes only through exact current registered identity fence`() { + val source = AppSourcePins.app("data/HostStore.kt") + assertTrue(source.contains("@Synchronized\n fun adoptRelay")) + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "fun adoptRelay("), + "val host = get(hostId) ?: return false", "!currentConnection()", "host.manual", + "host.hostKeyB64 != expectedHostKey", "relay.hostPublicKeyB64 != expectedHostKey", + "saveToken()", "save(_hosts.value.map") + val connection = AppSourcePins.app("conn/ConnectionManager.kt") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(connection, "private suspend fun adoptRelayIfAdvertised("), + "RelayApi(graph.hosts.apiBase).mintDevice(", + "graph.hosts.adoptRelay(host.id, host.hostKeyB64,", + "currentConnection = { lifetime.isCurrent(lease) && conn === ssh }", + "saveToken = { graph.secure.putString(tokenKey, minted.deviceToken) }") + assertFalse(AppSourcePins.blockAfter(connection, "private suspend fun adoptRelayIfAdvertised(") + .contains("graph.hosts.update(host.id)"), "Token and record publication must not be split around retirement") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingApprovalTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingApprovalTest.kt new file mode 100644 index 0000000000..495c30b280 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingApprovalTest.kt @@ -0,0 +1,101 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.pairing.PairingClient +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.pairing.PairingResult +import kotlinx.coroutines.runBlocking +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.net.InetAddress +import java.net.ServerSocket +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * What the phone takes from the `/pair` answer about relay approval (audits A07, A07-late), and what + * the Pair screen then says. `relayApproved` is what gates the background check's relay leg, so it + * must be true exactly when the computer will serve this phone's relay key without its dialog: a + * desktop that pinned the key (`relayPinned`, A07), or one that recorded it for its standing host to + * pin on the first relay handshake (`relayApproved`, A07-late). The current desktop's real answers + * are checked in [PairingInteropTest]; this covers the answers of desktops on either side of it. + */ +class PairingApprovalTest { + /** Answers ONE plaintext `/pair` POST with [body] (the payload has no hostKey, so nothing is sealed). */ + private fun answer(body: String): PairingResult { + val server = ServerSocket(0, 5, InetAddress.getLoopbackAddress()) + thread(isDaemon = true) { + server.use { + it.accept().use { sock -> + drain(sock.getInputStream()) + val bytes = body.toByteArray() + sock.getOutputStream().write("HTTP/1.1 200 OK\r\nContent-Length: ${bytes.size}\r\n\r\n".toByteArray() + bytes) + sock.getOutputStream().flush() + } + } + } + val payload = PairingPayload( + host = "127.0.0.1", port = 22, user = "u", token = "t", pairPort = server.localPort, name = "Test", + hostKey = null, relay = null, sshAvailable = true + ) + return runBlocking { PairingClient(readTimeoutMs = 5_000).pair(payload, "ssh-ed25519 AAAA test", "Pixel", "android-test") } + } + + private fun drain(input: InputStream) { + val head = ByteArrayOutputStream() + var last4 = 0 + while (last4 != 0x0d0a0d0a) { + val c = input.read() + if (c < 0) return + head.write(c) + last4 = (last4 shl 8) or c + } + val length = head.toString(Charsets.ISO_8859_1.name()).split("\r\n") + .firstNotNullOfOrNull { if (it.startsWith("Content-Length:", true)) it.substringAfter(':').trim().toInt() else null } ?: 0 + input.readNBytes(length) + } + + private val ok = """"ok":true,"deviceId":"dev-1","agentToken":"tok"""" + + @Test + fun `a desktop that only says relayPinned (A07) is read as approving the phone`() { + val r = answer("""{$ok,"relayDeviceToken":"dt","relayPinned":true}""") + assertTrue(r.relayPinned) + assertTrue(r.relayApproved) + } + + @Test + fun `a key recorded but not pinned (A07-late) is approved, not pinned`() { + val r = answer("""{$ok,"relayApproved":true}""") + assertFalse(r.relayPinned) + assertTrue(r.relayApproved) + } + + @Test + fun `an older desktop says neither, and the phone waits for the dialog`() { + val r = answer("{$ok}") + assertFalse(r.relayPinned) + assertFalse(r.relayApproved) + assertFalse(answer("""{$ok,"relayApproved":"yes"}""").relayApproved, "only a JSON true counts") + } + + @Test + fun `the Pair screen's sentence follows the relay leg and the approval`() { + fun result(token: String?, pinned: Boolean = false, approved: Boolean = pinned) = + PairingResult("d", "t", null, token, relayPinned = pinned, relayApproved = approved) + assertEquals("Paired. This phone reaches the computer on your network.", result(null).pairedNotice()) + // Remote access was off at the scan on a current desktop: nothing to approve later. + assertEquals( + "Paired. This phone reaches the computer on your network, and is approved for remote access once the computer offers it.", + result(null, approved = true).pairedNotice() + ) + assertEquals("Paired, and approved for remote access.", result("dt", pinned = true).pairedNotice()) + assertEquals("Paired, and approved for remote access.", result("dt", approved = true).pairedNotice()) + assertEquals( + "Paired. The first time you connect from outside your network, approve this phone on the computer.", + result("dt").pairedNotice() + ) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingClientBoundsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingClientBoundsTest.kt new file mode 100644 index 0000000000..f52aa7af67 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingClientBoundsTest.kt @@ -0,0 +1,272 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.pairing.PairingClient +import dev.nodeterm.protocol.pairing.PairingException +import dev.nodeterm.protocol.pairing.PairingPayload +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketTimeoutException +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Audit A54: the `/pair` answer comes from whatever `host:pairPort` the scanned (or linked) code + * names, so [PairingClient] must bound it — a declared length is checked before anything is + * allocated, a body with no length stops at the cap, and the whole exchange has a deadline that a + * trickling server cannot outlast. Each case is a scripted server on a local socket. + */ +class PairingClientBoundsTest { + private val cleanup = ArrayList() + + @AfterTest + fun tearDown() = cleanup.asReversed().forEach { runCatching { it.close() } } + + /** Accepts ONE connection, drains the request, then hands the socket to [script]. */ + private inner class ScriptedServer(private val script: ScriptedServer.(Socket) -> Unit) : AutoCloseable { + private val server = ServerSocket(0, 50, InetAddress.getLoopbackAddress()) + private val accepted = CopyOnWriteArrayList() + val port: Int get() = server.localPort + /** Counted down when the client's end of the connection is seen closed (read hits EOF). */ + val clientClosed = CountDownLatch(1) + + init { + cleanup += this + Thread({ + try { + val sock = server.accept() + accepted += sock + sock.use { + drainRequest(it.getInputStream()) + script(it) + } + } catch (_: IOException) { + // The client hung up mid-script: expected for every refusal. + } + }, "pair-bounds-server").apply { isDaemon = true }.start() + } + + /** Holds the connection open without sending more, until the client hangs up (or 10 s). */ + fun holdUntilClientCloses(sock: Socket) { + sock.soTimeout = 10_000 + try { + while (sock.getInputStream().read() >= 0) Unit + clientClosed.countDown() + } catch (_: SocketTimeoutException) { + // The client never hung up. + } catch (_: IOException) { + // A reset is also the client going away. + clientClosed.countDown() + } + } + + override fun close() { + accepted.forEach { runCatching { it.close() } } + server.close() + } + } + + private fun drainRequest(input: InputStream) { + val head = ByteArrayOutputStream() + var last4 = 0 + while (last4 != 0x0d0a0d0a) { + val c = input.read() + if (c < 0) return + head.write(c) + last4 = (last4 shl 8) or c + } + val length = head.toString(Charsets.ISO_8859_1.name()).split("\r\n") + .firstNotNullOfOrNull { line -> + if (line.startsWith("Content-Length:", ignoreCase = true)) line.substringAfter(':').trim().toInt() else null + } ?: 0 + var left = length + val buf = ByteArray(4096) + while (left > 0) { + val n = input.read(buf, 0, minOf(buf.size, left)) + if (n < 0) return + left -= n + } + } + + private fun payloadFor(port: Int) = PairingPayload( + host = "127.0.0.1", port = 22, user = "u", token = "t", pairPort = port, name = "Test", + hostKey = null, relay = null, sshAvailable = true + ) + + private fun pair(port: Int, client: PairingClient = PairingClient(readTimeoutMs = 5_000)) = runBlocking { + client.pair(payloadFor(port), "ssh-ed25519 AAAA test", "Pixel", "android-test") + } + + private fun Socket.send(text: String) { + getOutputStream().write(text.toByteArray(Charsets.ISO_8859_1)) + getOutputStream().flush() + } + + private val okBody = """{"ok":true,"deviceId":"dev-1","agentToken":"tok"}""" + + /** Every refusal the pairing screen can show must read as a sentence, never a bare token. */ + private fun assertSentence(e: PairingException) { + val msg = e.message!! + assertEquals(msg, PairingException.userMessage(e), "PairScreen shows a PairingException's message as it is") + assertTrue(msg.length > 40 && msg.trim().endsWith("."), "not a sentence: $msg") + } + + @Test + fun `a huge declared length is refused before anything is allocated`() { + // Int.MAX_VALUE used to be `ByteArray(length)` = OutOfMemoryError; the two longer ones did + // not parse as an Int and fell through to an unbounded read-to-EOF. + for (declared in listOf("2147483647", "99999999999", "99999999999999999999999999", "65537")) { + val srv = ScriptedServer { sock -> + sock.send("HTTP/1.1 200 OK\r\nContent-Length: $declared\r\n\r\n$okBody") + holdUntilClientCloses(sock) + } + val started = System.nanoTime() + val e = assertFailsWith("Content-Length $declared") { pair(srv.port) } + val tookMs = (System.nanoTime() - started) / 1_000_000 + assertTrue(e.message!!.contains("larger than any pairing answer"), e.message) + assertTrue(e.message!!.contains("64 KiB"), e.message) + assertSentence(e) + assertTrue(tookMs < 3_000, "refused from the header, not after a read timeout (${tookMs} ms)") + } + } + + @Test + fun `a negative or non-numeric length is refused with a sentence, not the bare number`() { + // A negative length used to reach PairScreen as NegativeArraySizeException's message: "-1". + val negative = ScriptedServer { sock -> sock.send("HTTP/1.1 200 OK\r\nContent-Length: -1\r\n\r\n$okBody") } + val e = assertFailsWith { pair(negative.port) } + assertNotEquals("-1", e.message) + assertTrue(e.message!!.contains("negative length (-1 bytes)"), e.message) + assertSentence(e) + + // Not a number at all used to be read as "no length" — and paired on a body RFC 9112 calls + // an unrecoverable framing error. + val garbage = ScriptedServer { sock -> sock.send("HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n$okBody") } + val g = assertFailsWith { pair(garbage.port) } + assertTrue(g.message!!.contains("not a number"), g.message) + assertSentence(g) + } + + @Test + fun `a body with no Content-Length stops at the cap instead of reading until EOF`() { + // Writes until the client hangs up; the 32 MiB bound only keeps a regressed client from + // reading forever. The deadline is set far away so the CAP is what has to stop it. + val srv = ScriptedServer { sock -> + sock.send("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n") + val chunk = ByteArray(8 * 1024) { 'x'.code.toByte() } + val out = sock.getOutputStream() + repeat(32 * 1024 * 1024 / chunk.size) { out.write(chunk) } + } + val started = System.nanoTime() + val e = assertFailsWith { pair(srv.port, PairingClient(readTimeoutMs = 5_000, deadlineMs = 30_000)) } + val tookMs = (System.nanoTime() - started) / 1_000_000 + assertTrue(e.message!!.contains("ran past 64 KiB"), e.message) + assertSentence(e) + assertTrue(tookMs < 5_000, "stopped by the cap, not by a timeout (${tookMs} ms)") + } + + @Test + fun `an answer exactly at the cap is still read, with or without a length`() { + // The boundary is inclusive: pad a real answer with JSON whitespace to exactly 64 KiB. + val padded = okBody + " ".repeat(PairingClient.MAX_RESPONSE_BYTES - okBody.length) + assertEquals(PairingClient.MAX_RESPONSE_BYTES, padded.toByteArray().size) + val withLength = ScriptedServer { sock -> + sock.send("HTTP/1.1 200 OK\r\nContent-Length: ${padded.length}\r\n\r\n$padded") + } + assertEquals("dev-1", pair(withLength.port).deviceId) + val toEof = ScriptedServer { sock -> sock.send("HTTP/1.1 200 OK\r\n\r\n$padded") } + assertEquals("dev-1", pair(toEof.port).deviceId) + } + + @Test + fun `a server that trickles bytes is cut off at the overall deadline`() { + // One byte every 100 ms never trips the 5 s per-read timeout; only the deadline can end + // this. The server gives up after 10 s so a regressed client fails instead of hanging. + val srv = ScriptedServer { sock -> + sock.send("HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n") + val until = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < until) { + sock.send(" ") + Thread.sleep(100) + } + } + val started = System.nanoTime() + val e = assertFailsWith { + pair(srv.port, PairingClient(readTimeoutMs = 5_000, deadlineMs = 1_500)) + } + val tookMs = (System.nanoTime() - started) / 1_000_000 + assertTrue(e.message!!.contains("did not finish answering within 2 seconds"), e.message) + assertSentence(e) + assertTrue(tookMs in 1_400..4_500, "cut off by the 1.5 s deadline, not the 5 s read timeout (${tookMs} ms)") + } + + @Test + fun `cancelling the caller closes the socket so a blocked read returns at once`() { + // A silent server: the client is parked in a read. Cancellation alone cannot unblock it; + // closing the socket can. Without that the call outlives its caller by the read timeout. + val holder = ScriptedServer { sock -> holdUntilClientCloses(sock) } + val started = System.nanoTime() + assertFailsWith { + runBlocking { + withTimeout(500) { + PairingClient(readTimeoutMs = 5_000, deadlineMs = 30_000) + .pair(payloadFor(holder.port), "ssh-ed25519 AAAA test", "Pixel", "android-test") + } + } + } + val tookMs = (System.nanoTime() - started) / 1_000_000 + assertTrue(tookMs < 3_000, "returned with its caller, not after the 5 s read timeout (${tookMs} ms)") + assertTrue(holder.clientClosed.await(3, TimeUnit.SECONDS), "the server saw the phone hang up") + } + + @Test + fun `a refusal body is shown as one bounded line`() { + // The desktop's own refusals are one plain sentence and reach the screen unchanged. + val desktop = "remote access is off on the computer. On Windows the phone connects only through remote access." + val plain = ScriptedServer { sock -> sock.send("HTTP/1.1 409 Conflict\r\nContent-Length: ${desktop.length}\r\n\r\n$desktop") } + assertEquals("The computer rejected pairing: $desktop", assertFailsWith { pair(plain.port) }.message) + + // Whatever else answers on the pairing port may send anything: line breaks, escape + // sequences, pages of text. The screen gets one line, cut at the cap. + val hostile = "line one\r\n\u001b[31mred\u001b[0m\tand " + "x".repeat(10_000) + val bytes = hostile.toByteArray(Charsets.UTF_8) + val noisy = ScriptedServer { sock -> + sock.send("HTTP/1.1 403 Forbidden\r\nContent-Length: ${bytes.size}\r\n\r\n") + sock.getOutputStream().write(bytes) + sock.getOutputStream().flush() + } + val msg = assertFailsWith { pair(noisy.port) }.message!! + assertTrue(msg.startsWith("The computer rejected pairing: line one [31mred [0m and xxx"), msg) + assertTrue(msg.none { it.isISOControl() }, "control characters reached the screen") + assertTrue(msg.endsWith("…"), msg) + assertEquals("The computer rejected pairing: ".length + PairingException.MAX_REFUSAL_CHARS + 1, msg.length) + + // Nothing printable left: the status code, not an empty quote. + val blank = ScriptedServer { sock -> sock.send("HTTP/1.1 403 Forbidden\r\nContent-Length: 3\r\n\r\n\r\n\u0007") } + assertEquals("The computer rejected pairing (HTTP 403).", assertFailsWith { pair(blank.port) }.message) + } + + @Test + fun `anything that is not a PairingException is framed as a sentence`() { + val framed = PairingException.userMessage(NegativeArraySizeException("-1")) + assertEquals("Pairing failed unexpectedly (NegativeArraySizeException: -1). Scan the code again.", framed) + assertEquals( + "Pairing failed unexpectedly (IllegalStateException). Scan the code again.", + PairingException.userMessage(IllegalStateException()) + ) + assertEquals("The computer rejected pairing: bad token", PairingException.userMessage(PairingException("The computer rejected pairing: bad token"))) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt new file mode 100644 index 0000000000..bbceeae89d --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingInteropTest.kt @@ -0,0 +1,434 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.pairing.PairingClient +import dev.nodeterm.protocol.pairing.PairingException +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.pairing.SshIdentity +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.ssh.HostKeyAnchors +import dev.nodeterm.protocol.ssh.HostKeyNotPairedException +import dev.nodeterm.protocol.ssh.HostKeyPin +import dev.nodeterm.protocol.ssh.SshHostConnection +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.KeyType +import org.apache.sshd.server.SshServer +import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.file.Files +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The phone's `/pair` exchange against the DESKTOP's real `createPairingService` + * (src/main/pairing-service.ts), with the home dir pointed at a temp dir so the authorized_keys line + * it writes can be inspected, and so no run pairs a test device into a real profile + * ([InteropHarness.scratchHomeEnv]: HOME and, for Windows, USERPROFILE). Covers the E2EE-sealed + * exchange (the QR carries `hostKey`) with and without a relay leg, on the direct-SSH path whatever OS + * runs it (the fixture pins a non-Windows platform; audit A70). + */ +class PairingInteropTest { + private val cleanup = ArrayList() + + @AfterTest + fun tearDown() = cleanup.forEach { it.close() } + + private fun start(withRelay: Boolean, env: Map = emptyMap()): Pair { + val home = Files.createTempDirectory("nt-pair-home").toFile() + cleanup += AutoCloseable { home.deleteRecursively() } + val h = InteropHarness.start( + "pair", + InteropHarness.scratchHomeEnv(home) + + mapOf("FIXTURE_USERDATA" to home.path, "FIXTURE_RELAY" to if (withRelay) "1" else "0") + env + ) + cleanup += h + return h to home + } + + @Test + fun `pair mode refuses to start unless the home dir is the scratch dir it was given`() { + // A70: os.homedir() reads HOME on POSIX and USERPROFILE on Windows. A caller that points the + // wrong variable at its temp dir must fail here, before the service writes agent.json (a live + // bearer token) and authorized_keys into whatever the real home is. + assumeTrue(InteropHarness.available(), "node + repo node_modules (npm ci) are needed for interop tests") + val home = Files.createTempDirectory("nt-pair-home").toFile() + val other = Files.createTempDirectory("nt-pair-other").toFile() + cleanup += AutoCloseable { home.deleteRecursively(); other.deleteRecursively() } + val err = assertFailsWith { + InteropHarness.start( + "pair", + InteropHarness.scratchHomeEnv(home) + mapOf("FIXTURE_HOME" to other.path, "FIXTURE_USERDATA" to home.path) + ) + } + assertTrue(err.message.orEmpty().contains("needs os.homedir() to be FIXTURE_HOME"), err.message) + assertTrue(home.listFiles().isNullOrEmpty() && other.listFiles().isNullOrEmpty(), "a refused start wrote files") + } + + private fun payloadOf(h: InteropHarness): PairingPayload = + assertNotNull(PairingPayload.parse(h.ready["payload"]!!.jsonPrimitive.content), "the desktop's QR must parse") + + @Test + fun `an E2EE pairing installs our key under the desktop's attributable comment`() = runBlocking { + val (h, home) = start(withRelay = false) + val payload = payloadOf(h) + assertEquals(h.ready["hostPublicKeyB64"]!!.jsonPrimitive.content, payload.hostKey, "QR carries the host box key") + assertTrue(payload.sshAvailable) + assertNull(payload.relay) + val identity = SshIdentity.generate() + val result = PairingClient().pair(payload, identity.authorizedKeysLine(), "Pixel Test", "android-device-1") + assertTrue(result.deviceId.isNotBlank()) + assertTrue(result.agentToken.isNotBlank()) + assertNull(result.relayDeviceToken) + val done = h.awaitEvent("done") + assertEquals("true", done["ok"]!!.jsonPrimitive.content) + val keys = File(home, ".ssh/authorized_keys").readText() + assertTrue( + keys.contains("ssh-ed25519 ${B64.encode(identity.publicKeyBlob)} nodeterm-mobile-${result.deviceId}"), + "authorized_keys: $keys" + ) + val agent = File(home, ".nodeterm/agent.json").readText() + assertTrue(agent.contains("\"name\": \"Pixel Test\""), agent) + assertTrue(agent.contains("\"relayDeviceId\": \"android-device-1\""), agent) + // The fixture's host-key dir does not exist unless a test lays one out: no keys, no anchors. + assertEquals(emptyList(), result.sshHostKeyFingerprints) + } + + /** Writes each key as sshd's `ssh_host__key.pub` (` `) under a new dir. */ + private fun hostKeyDir(vararg keys: Pair): File { + val dir = Files.createTempDirectory("nt-etc-ssh").toFile() + cleanup += AutoCloseable { dir.deleteRecursively() } + for ((name, key) in keys) { + val blob = Buffer.PlainBuffer().putPublicKey(key).compactData + File(dir, "ssh_host_${name}_key.pub").writeText("${KeyType.fromKey(key)} ${java.util.Base64.getEncoder().encodeToString(blob)} root@box\n") + } + return dir + } + + private fun ecKey(): java.security.PublicKey = + java.security.KeyPairGenerator.getInstance("EC").apply { initialize(256) }.generateKeyPair().public + + /** A real SSH server that lets [identity] in as `dev`, and its host key. */ + private fun sshServer(identity: SshIdentity): Pair { + val root = Files.createTempDirectory("nt-pair-sshd").toFile() + cleanup += AutoCloseable { root.deleteRecursively() } + val server = SshServer.setUpDefaultServer() + server.host = "127.0.0.1" + server.port = 0 + server.keyPairProvider = SimpleGeneratorHostKeyProvider(File(root, "hostkey.ser").toPath()) + val accepted = identity.keyPair.public.encoded + server.publickeyAuthenticator = PublickeyAuthenticator { user, key, _ -> user == "dev" && key.encoded.contentEquals(accepted) } + server.start() + cleanup += AutoCloseable { server.stop(true) } + return server to server.keyPairProvider.loadKeys(null).first().public + } + + private class Pin(private val paired: List) : HostKeyPin { + var value: String? = null + override fun pinned() = value + override fun pin(fingerprint: String) { + value = fingerprint + } + override fun anchors() = paired + } + + @Test + fun `the sealed answer names the computer's SSH host keys, and the first connect must present one (A49-anchor)`() = runBlocking { + // The desktop's real pairing service reads a host-key dir laid out like /etc/ssh, holding the + // key of a real SSH server and one more (sshd serves one of several keys per connection). + val identity = SshIdentity.generate() + val (sshd, serverKey) = sshServer(identity) + val other = ecKey() + val keys = hostKeyDir("ecdsa" to serverKey, "other" to other) + val (h, _) = start(withRelay = false, env = mapOf("FIXTURE_SSH_HOST_KEY_DIR" to keys.path)) + val payload = payloadOf(h) + assertTrue(!h.ready["payload"]!!.jsonPrimitive.content.contains("SHA256:"), "the QR carries no SSH host key") + val result = PairingClient().pair(payload, identity.authorizedKeysLine(), "Pixel", "android-device-7") + // In the form sshj reports, in the order the desktop read the files. + val serverFp = SshHostConnection.fingerprint(serverKey) + assertEquals(listOf(serverFp, SshHostConnection.fingerprint(other)), result.sshHostKeyFingerprints) + val host = PairedHost.from(payload, result) + assertEquals(result.sshHostKeyFingerprints, host.sshHostKeyAnchors) + assertNull(host.sshHostKeyFingerprint, "nothing is pinned before a connect has authenticated") + assertEquals(host, PairedHost.fromJson(host.toJson()), "the anchors survive the phone's own record") + + // The first connect presents a key the pairing named: it connects and pins exactly that one. + val pin = Pin(host.sshHostKeyAnchors) + SshHostConnection.connect("127.0.0.1", sshd.port, "dev", identity, pin).close() + assertEquals(serverFp, pin.value) + + // Had the pairing named only the other key, the same server is refused and nothing is pinned. + val elsewhere = Pin(listOf(SshHostConnection.fingerprint(other))) + assertFailsWith { + SshHostConnection.connect("127.0.0.1", sshd.port, "dev", identity, elsewhere).close() + } + assertNull(elsewhere.value) + } + + @Test + fun `sealed anchors discover a served SSH key through recursive external Includes (A49-anchor)`() = runBlocking { + // Windows desktops advertise relay-only. This fixture models POSIX sshd paths and its + // /etc/ssh include root with an injected scratch directory, never the machine's config. + assumeTrue(!System.getProperty("os.name").startsWith("Windows"), "POSIX sshd configuration") + val identity = SshIdentity.generate() + val (sshd, serverKey) = sshServer(identity) + val root = hostKeyDir("fixture" to serverKey) + val external = Files.createTempDirectory("nt-ssh include-").toFile() + cleanup += AutoCloseable { external.deleteRecursively() } + val nested = File(external, "nested").apply { mkdir() } + val public = File(external, "custom-key.pub") + assertTrue(File(root, "ssh_host_fixture_key.pub").renameTo(public)) + File(external, "custom-key").writeText("PRIVATE HOSTKEY SENTINEL — never read for its fingerprint\n") + File(root, "sshd_config").writeText("Include \"${external.path}/outer*.conf\"\n") + // Even an Include in an external file resolves its relative value under the original + // config root, not that external file's parent directory. + File(external, "outer.conf").writeText("Include nested/*.conf\n") + val correctNested = File(root, "nested").apply { mkdir() } + File(correctNested, "key.conf").writeText("HostKey \"${File(external, "custom-key").path}\"\n") + File(nested, "wrong.conf").writeText("HostKey /no-such-fixture-key\n") + + val (h, _) = start(withRelay = false, env = mapOf("FIXTURE_SSH_HOST_KEY_DIR" to root.path)) + val payload = payloadOf(h) + assertTrue(!h.ready["payload"]!!.jsonPrimitive.content.contains("SHA256:"), "anchors stay inside the sealed answer") + val paired = PairingClient().pair(payload, identity.authorizedKeysLine(), "Pixel", "android-include-device") + val expected = SshHostConnection.fingerprint(serverKey) + assertEquals(listOf(expected), paired.sshHostKeyFingerprints) + val host = PairedHost.from(payload, paired) + assertEquals(listOf(expected), assertNotNull(PairedHost.fromJson(host.toJson())).sshHostKeyAnchors) + val pin = Pin(host.sshHostKeyAnchors) + SshHostConnection.connect("127.0.0.1", sshd.port, "dev", identity, pin).close() + assertEquals(expected, pin.value, "the included key authenticates the phone's first SSH connect") + } + + @Test + fun `relative private HostKey names never add anchors through an Include (A117)`() = runBlocking { + assumeTrue(!System.getProperty("os.name").startsWith("Windows"), "POSIX sshd configuration") + val identity = SshIdentity.generate() + val (served, servedKey) = sshServer(identity) + val (unpaired, unpairedKey) = sshServer(identity) + val root = hostKeyDir("fixture" to servedKey) + val external = hostKeyDir("rogue" to unpairedKey) + val roguePublic = File(external, "rogue.pub") + assertTrue(File(external, "ssh_host_rogue_key.pub").renameTo(roguePublic)) + // A synthetic private-file sentinel deliberately contains a valid config directive. If + // Include opens it, the real producer incorrectly seals the unpaired server's public key. + // Nothing here contains or reads an actual private host key. + val privateSentinel = File(external, "custom-host") + privateSentinel.writeText("HostKey \"${roguePublic.path}\"\n") + Files.createSymbolicLink(File(external, "alias.conf").toPath(), privateSentinel.toPath()) + File(root, "sshd_config").writeText("HostKey ../keys/custom-host\nInclude \"${external.path}/*\"\n") + + val (h, _) = start(withRelay = false, env = mapOf("FIXTURE_SSH_HOST_KEY_DIR" to root.path)) + val payload = payloadOf(h) + assertTrue(!h.ready["payload"]!!.jsonPrimitive.content.contains("SHA256:"), "anchors remain sealed") + val paired = PairingClient().pair(payload, identity.authorizedKeysLine(), "Pixel", "android-relative-key") + val expected = SshHostConnection.fingerprint(servedKey) + assertEquals(listOf(expected), paired.sshHostKeyFingerprints, "a private Include cannot advertise another server") + val host = assertNotNull(PairedHost.fromJson(PairedHost.from(payload, paired).toJson())) + assertEquals(listOf(expected), host.sshHostKeyAnchors) + val pin = Pin(host.sshHostKeyAnchors) + SshHostConnection.connect("127.0.0.1", served.port, "dev", identity, pin).close() + assertEquals(expected, pin.value) + val refused = Pin(host.sshHostKeyAnchors) + assertFailsWith { + SshHostConnection.connect("127.0.0.1", unpaired.port, "dev", identity, refused).close() + } + assertNull(refused.value, "the excluded server must never pin") + } + + @Test + fun `a plaintext answer carries no SSH host keys, whatever the computer has (A49-anchor)`() = runBlocking { + // No `hostKey` in the QR: the phone pairs in the clear, and a clear answer could have been + // rewritten on the LAN, so the desktop leaves the keys out of it. + val keys = hostKeyDir("ecdsa" to ecKey()) + val (h, _) = start(withRelay = false, env = mapOf("FIXTURE_SSH_HOST_KEY_DIR" to keys.path)) + val result = PairingClient().pair(payloadOf(h).copy(hostKey = null), SshIdentity.generate().authorizedKeysLine(), "Pixel", "android-device-8") + assertTrue(result.deviceId.isNotBlank()) + assertEquals(emptyList(), result.sshHostKeyFingerprints) + } + + @Test + fun `on a Windows host the fixture still pairs over the direct-SSH path`() = runBlocking { + // A70: on win32 the desktop pairs relay-only (no key, the QR says ssh:false; covered by + // src/main/pairing-service.windows.test.ts), so without the fixture pinning a non-Windows + // platform every test here that expects an SSH key would fail on Windows. The fixture and the + // service read process.platform at call time, which FIXTURE_PROCESS_PLATFORM stands in for. + val (h, home) = start(withRelay = false, env = mapOf("FIXTURE_PROCESS_PLATFORM" to "win32")) + val payload = payloadOf(h) + assertTrue(payload.sshAvailable, "the QR must offer SSH") + val identity = SshIdentity.generate() + val result = PairingClient().pair(payload, identity.authorizedKeysLine(), "Pixel", "android-device-6") + assertEquals("true", h.awaitEvent("done")["ok"]!!.jsonPrimitive.content) + assertTrue( + File(home, ".ssh/authorized_keys").readText().contains("nodeterm-mobile-${result.deviceId}"), + "the key must land in the scratch home's authorized_keys" + ) + } + + @Test + fun `with remote access on, the sealed answer carries the relay leg`() = runBlocking { + val (h, _) = start(withRelay = true) + val payload = payloadOf(h) + val relay = assertNotNull(payload.relay, "QR relay block") + assertEquals("wss://relay.example.test", relay.relayEndpoint) + val result = PairingClient().pair(payload, SshIdentity.generate().authorizedKeysLine(), "Pixel", "android-device-2", priorDeviceToken = "old-token") + assertEquals("device-token-xyz", result.relayDeviceToken) + assertEquals("minted-host-id", result.relay?.hostId) + val api = h.awaitEvent("api") + val body = api["body"]!!.jsonObject + assertEquals("android-device-2", body["deviceId"]!!.jsonPrimitive.content, "the backend row is keyed by OUR id") + assertEquals("old-token", body["priorDeviceToken"]!!.jsonPrimitive.content) + assertEquals("Pixel", body["label"]!!.jsonPrimitive.content) + } + + @Test + fun `a wrong token is refused with the desktop's own words`() = runBlocking { + val (h, home) = start(withRelay = false) + val forged = payloadOf(h).copy(token = "not-the-token") + val e = assertFailsWith { + PairingClient().pair(forged, SshIdentity.generate().authorizedKeysLine(), "x", "y") + } + assertTrue(e.message!!.contains("bad token"), e.message) + assertTrue(!File(home, ".ssh/authorized_keys").exists()) + } + + @Test + fun `the phone's relay key rides the sealed body and the desktop pins it`() = runBlocking { + // A07: approving at the scan, so the first remote connect needs nobody at the desk. + val (h, _) = start(withRelay = true) + val box = dev.nodeterm.protocol.crypto.BoxKeyPair.generate() + val result = PairingClient().pair( + payloadOf(h), SshIdentity.generate().authorizedKeysLine(), "Pixel", "android-device-3", boxPublicKeyB64 = box.publicKeyB64 + ) + assertTrue(result.relayPinned) + assertTrue(result.relayApproved) + assertEquals(box.publicKeyB64, h.awaitEvent("pin")["pub"]!!.jsonPrimitive.content) + } + + @Test + fun `the desktop's largest real answer sits far below the client's response cap`() = runBlocking { + // A54 caps the /pair answer at PairingClient.MAX_RESPONSE_BYTES. This measures the biggest + // answer the desktop's real service gives — sealed, with the relay leg, the pin and as many + // SSH host keys as it sends (A49-anchor) — through a byte-counting proxy, so a desktop change + // that grows it toward the cap is caught here. + val keys = hostKeyDir(*Array(HostKeyAnchors.MAX + 2) { "k%02d".format(it) to ecKey() }) + val (h, _) = start(withRelay = true, env = mapOf("FIXTURE_SSH_HOST_KEY_DIR" to keys.path)) + val payload = payloadOf(h) + val proxy = CountingProxy(payload.host, payload.pairPort) + cleanup += proxy + val result = PairingClient().pair( + payload.copy(host = "127.0.0.1", pairPort = proxy.port), + SshIdentity.generate().authorizedKeysLine(), "Pixel", "android-device-5", + boxPublicKeyB64 = BoxKeyPair.generate().publicKeyB64 + ) + assertTrue(result.relayPinned) + assertEquals(HostKeyAnchors.MAX, result.sshHostKeyFingerprints.size, "the desktop sends at most as many keys as the phone keeps") + val answered = proxy.awaitDownstreamBytes() + println("[pair] the desktop's sealed answer with relay leg + pin + SSH host keys: $answered bytes, headers included") + assertTrue(answered in 1..(PairingClient.MAX_RESPONSE_BYTES / 16).toLong(), "answer was $answered bytes") + } + + /** Forwards one connection to [host]:[targetPort] and counts the bytes coming back. */ + private class CountingProxy(host: String, targetPort: Int) : AutoCloseable { + private val server = ServerSocket(0, 5, InetAddress.getLoopbackAddress()) + private val downstream = AtomicLong() + private val done = CountDownLatch(1) + private val sockets = CopyOnWriteArrayList() + val port: Int get() = server.localPort + + init { + Thread({ + try { + val client = server.accept().also { sockets += it } + val upstream = Socket(host, targetPort).also { sockets += it } + Thread({ pump(client.getInputStream(), upstream.getOutputStream(), null) }, "proxy-up") + .apply { isDaemon = true }.start() + pump(upstream.getInputStream(), client.getOutputStream(), downstream) + } catch (_: IOException) { + } finally { + sockets.forEach { runCatching { it.close() } } + done.countDown() + } + }, "proxy-down").apply { isDaemon = true }.start() + } + + private fun pump(from: InputStream, to: OutputStream, count: AtomicLong?) { + val buf = ByteArray(8192) + try { + while (true) { + val n = from.read(buf) + if (n < 0) break + to.write(buf, 0, n) + to.flush() + count?.addAndGet(n.toLong()) + } + } catch (_: IOException) { + } + } + + fun awaitDownstreamBytes(): Long { + done.await(10, TimeUnit.SECONDS) + return downstream.get() + } + + override fun close() { + sockets.forEach { runCatching { it.close() } } + server.close() + } + } + + @Test + fun `without a relay leg the key is recorded, not pinned, and approved on the first relay handshake`() = runBlocking { + // A07-late: paired while remote access is off, the phone adopts the relay later over SSH + // (relay.json), and its first relay connect is usually made away from the desk. The desktop's + // real service answers what the standing host asks on that handshake, for the key this client + // sent and for a stranger's, and again once the device is revoked. + val box = BoxKeyPair.generate() + val stranger = BoxKeyPair.generate() + val (h, home) = start( + withRelay = false, + env = mapOf("FIXTURE_LATE_PIN_KEYS" to "${box.publicKeyB64},${stranger.publicKeyB64}") + ) + val result = PairingClient().pair( + payloadOf(h), SshIdentity.generate().authorizedKeysLine(), "Pixel", "android-device-4", boxPublicKeyB64 = box.publicKeyB64 + ) + assertNull(result.relayDeviceToken) + assertTrue(!result.relayPinned) + assertTrue(result.relayApproved, "the phone may let its background check use a relay it adopts later") + // Not pinned at the scan: a pin is written before the answer, and `done` follows the answer. + val first = h.await { it["event"]?.jsonPrimitive?.content in setOf("pin", "done") } + assertEquals("done", first["event"]!!.jsonPrimitive.content) + assertTrue(File(home, ".nodeterm/agent.json").readText().contains("\"relayBoxKey\": \"${box.publicKeyB64}\"")) + + fun approved(stage: String, pub: String): Boolean = h.await { + it["event"]?.jsonPrimitive?.content == "late-approval" && + it["when"]?.jsonPrimitive?.content == stage && it["pub"]?.jsonPrimitive?.content == pub + }["approved"]!!.jsonPrimitive.content.toBooleanStrict() + assertTrue(approved("paired", box.publicKeyB64), "the handshake from the phone's key is approved") + assertTrue(!approved("paired", stranger.publicKeyB64), "a key no pairing recorded gets the dialog") + assertEquals(box.publicKeyB64, h.awaitEvent("late-pin")["pub"]!!.jsonPrimitive.content) + assertEquals(box.publicKeyB64, h.awaitEvent("revoke-relay-key")["pub"]!!.jsonPrimitive.content) + assertTrue(!approved("revoked", box.publicKeyB64), "a revoked pairing approves nothing") + assertTrue(!approved("revoked", stranger.publicKeyB64)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingNetworkInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingNetworkInteropTest.kt new file mode 100644 index 0000000000..6044943273 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PairingNetworkInteropTest.kt @@ -0,0 +1,135 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.pairing.PairingPayload +import dev.nodeterm.protocol.pairing.PairingResult +import dev.nodeterm.protocol.ssh.LanRefresh +import dev.nodeterm.protocol.ssh.LanReport +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Actual desktop adapter policy → QR builder / LAN reporter → Android parsers and refresh policy. + * The Node fixture explicitly replaces the OS interface table, not the payload or report. The + * pairing answer below supplies inert authentication facts at the already-authenticated boundary; + * HTTP pairing and the encrypted relay handshake have their own Pairing/RelayInteropTest coverage. + */ +class PairingNetworkInteropTest { + private fun fixture(check: (JsonObject, String) -> Unit) { + val root = Files.createTempDirectory("nt-pairing-network").toFile() + try { + val keys = File(root, "keys").apply { mkdir() } + // A public-only synthetic SSH key, in the exact sshd .pub wire format. No SSH service. + val bytes = ByteArrayOutputStream().also { output -> + DataOutputStream(output).use { data -> + val algorithm = "ssh-ed25519".toByteArray(Charsets.US_ASCII) + data.writeInt(algorithm.size); data.write(algorithm) + data.writeInt(32); data.write(ByteArray(32) { (it + 1).toByte() }) + } + }.toByteArray() + File(keys, "ssh_host_ed25519_key.pub").writeText("ssh-ed25519 ${B64.encode(bytes)} fixture\n") + val expected = "SHA256:" + B64.encode(MessageDigest.getInstance("SHA-256").digest(bytes)).trimEnd('=') + InteropHarness.start("pairing-network", mapOf("FIXTURE_USERDATA" to root.path, + "FIXTURE_SSH_HOST_KEY_DIR" to keys.path)).use { check(it.ready, expected) } + } finally { root.deleteRecursively() } + } + + private fun cases(ready: JsonObject): Map = + ready["cases"]!!.jsonArray.map { it.jsonObject }.associateBy { it["name"]!!.jsonPrimitive.content } + + private fun payload(case: JsonObject): PairingPayload = + assertNotNull(PairingPayload.parse(case["payload"]!!.jsonPrimitive.content), "actual desktop QR must parse") + + private fun report(case: JsonObject): LanReport = assertNotNull(LanReport.parse(case["lan"])) + + @Test + fun `selected adapter QR and relay report follow DHCP and a changed selection from one live reporter`() = fixture { ready, fingerprint -> + val cases = cases(ready) + for ((name, expected) in listOf("selected" to "192.168.1.42", "dhcp-moved" to "192.168.1.77", + "selection-changed" to "10.7.0.2", "automatic-physical" to "192.168.1.42", + "automatic-virtual-only" to "10.7.0.2")) { + val case = cases.getValue(name) + assertEquals(expected, payload(case).host, name) + assertEquals(expected, report(case).host, "same policy for the QR and reporter: $name") + assertEquals(listOf(fingerprint), report(case).sshHostKeyFingerprints, "public anchors are independent of the adapter") + } + val choices = cases.getValue("selected")["choices"]!!.jsonArray.map { it.jsonObject } + assertEquals(listOf("docker0", "wg0", "wlan0"), choices.map { it["interfaceName"]!!.jsonPrimitive.content }) + assertFalse(choices.any { it["address"]!!.jsonPrimitive.content.startsWith("127.") }) + } + + @Test + fun `authenticated LAN adoption changes only the dial address retaining paired host identity and SSH pins`() = fixture { ready, fingerprint -> + val cases = cases(ready) + val qr = payload(cases.getValue("selected")) + val paired = PairingResult("fixture-device", "inert-agent-token", qr.relay, "inert-device-token", + relayPinned = true, relayApproved = true, sshHostKeyFingerprints = listOf(fingerprint)) + val initial = PairedHost.from(qr, paired, now = 1234).copy(sshHostKeyFingerprint = fingerprint) + val listing = ProjectsSnapshot.EMPTY.copy(fetchedAt = 2000, lan = report(cases.getValue("dhcp-moved"))) + assertNull(LanRefresh.afterListing(initial, TransportKind.SSH, listing, refusedHostKey = null), "SSH cannot authenticate its own address report") + val change = assertNotNull(LanRefresh.afterListing(initial, TransportKind.RELAY, listing, refusedHostKey = null)) + assertEquals(initial.copy(host = "192.168.1.77"), change.host) + assertTrue(change.addressChanged) + assertNull(change.confirmedKey, "a DHCP change does not reset authentication") + assertEquals(fingerprint, change.host.sshHostKeyFingerprint) + assertEquals(initial, change.previous) + assertEquals(change.host, PairedHost.fromJson(change.host.toJson()), "normal persisted host retains all identity and pin fields") + assertEquals("fixture-device", change.host.id) + assertEquals(qr.hostKey, change.host.hostKeyB64) + assertEquals(qr.relay, change.host.relay) + } + + @Test + fun `a missing selected adapter emits no replacement address despite another usable VPN and Docker network`() = fixture { ready, fingerprint -> + val cases = cases(ready) + val missing = cases.getValue("selected-missing") + assertEquals(JsonNull, missing["payload"], "pairing cannot advertise the wrong adapter") + assertNull(missing["lan"]!!.jsonObject["host"], "the reporter omits an unknown selected address") + assertEquals(listOf(fingerprint), report(missing).sshHostKeyFingerprints) + val paired = PairedHost.from(payload(cases.getValue("selected")), + PairingResult("fixture-device", "inert-agent-token", null, "inert-device-token", + sshHostKeyFingerprints = listOf(fingerprint)), now = 1234).copy(sshHostKeyFingerprint = fingerprint) + assertNull(LanRefresh.afterListing(paired, TransportKind.RELAY, + ProjectsSnapshot.EMPTY.copy(fetchedAt = 2000, lan = report(missing)), refusedHostKey = null)) + assertEquals("192.168.1.42", paired.host) + assertEquals(fingerprint, paired.sshHostKeyFingerprint) + } + + @Test + fun `Windows QR route hints require a current eligible address and cannot override explicit adapter selection`() = fixture { ready, _ -> + val windows = ready["windows"]!!.jsonObject + for ((name, expected) in listOf("currentRoute" to "192.168.1.99", "staleRoute" to "192.168.1.42", + "explicitAdapter" to "10.7.0.2")) { + val qr = assertNotNull(PairingPayload.parse(windows[name]!!.jsonPrimitive.content)) + assertEquals(expected, qr.host, name) + assertFalse(qr.sshAvailable, "Windows is still relay-only") + assertNotNull(qr.relay) + } + assertEquals(JsonNull, windows["lan"], "Windows has no direct SSH leg to refresh") + } + + @Test + fun `network interop bundles the actual adapter policy QR builder and LAN reporter`() = fixture { _, _ -> + val inputs = Json.parseToJsonElement(InteropHarness.bundleMeta.readText()).jsonObject["inputs"]!!.jsonObject.keys + for (source in listOf("src/shared/pairing-network.ts", "src/main/pairing-core.ts", "src/main/remote/host-lan-report.ts")) + assertTrue(source in inputs, "actual producer is missing: $source") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PhoneIdentityTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PhoneIdentityTest.kt new file mode 100644 index 0000000000..75815b89a8 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PhoneIdentityTest.kt @@ -0,0 +1,195 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.secure.PhoneIdentity +import dev.nodeterm.protocol.secure.PlainStorage +import dev.nodeterm.protocol.secure.SecretStorage +import dev.nodeterm.protocol.secure.SecretStoreCore +import dev.nodeterm.protocol.secure.SecretUnavailableException +import dev.nodeterm.protocol.secure.Sealer +import java.security.ProviderException +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Audit A51: an Android 12+ device-to-device transfer copied the phone's plain preferences (the + * paired computers and the relay `deviceId`) but not its Keystore key, so the new phone regenerated + * its box key and went on presenting the OLD deviceId beside it. [PhoneIdentity] keeps the two + * together: a deviceId never outlives the box secret it was minted with. The manifest half (the data + * extraction rules) is [BackupRulesTest]. + */ +class PhoneIdentityTest { + /** Both stores write into one journal, so the ORDER across the two preference files is visible. */ + private val journal = ArrayList() + + private inner class SecretMem : SecretStorage { + val map = HashMap() + override fun get(name: String) = map[name] + override fun put(name: String, value: String, durable: Boolean) { + map[name] = value + journal += "secret.put $name durable=$durable" + } + override fun remove(name: String) { + map.remove(name) + journal += "secret.remove $name" + } + } + + private inner class PlainMem : PlainStorage { + val map = HashMap() + override fun get(name: String) = map[name] + override fun put(name: String, value: String, durable: Boolean) { + map[name] = value + journal += "plain.put $name durable=$durable" + } + override fun remove(name: String, durable: Boolean) { + map.remove(name) + journal += "plain.remove $name durable=$durable" + } + } + + /** Real AES-GCM (the Keystore does the same), plus a switch to fail like a busy keystore. */ + private class GcmSealer(var key: SecretKey = newKey()) : Sealer { + var failOpenWith: Exception? = null + override fun seal(plain: ByteArray): ByteArray { + val iv = ByteArray(12).also { SecureRandom().nextBytes(it) } + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv)) + return iv + c.doFinal(plain) + } + override fun open(sealed: ByteArray): ByteArray { + failOpenWith?.let { throw it } + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed, 0, 12)) + return c.doFinal(sealed, 12, sealed.size - 12) + } + companion object { + fun newKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + } + } + + private val secretMem = SecretMem() + private val plainMem = PlainMem() + private val sealer = GcmSealer() + private var minted = 0 + + /** A fresh process over the same storage (what an app restart is). */ + private fun process() = PhoneIdentity(SecretStoreCore(secretMem, sealer), plainMem) { "device-${++minted}" } + + @Test + fun `a fresh phone creates the box key first, then its deviceId, both durably`() { + val id = process().deviceId() + assertEquals("device-1", id) + assertEquals( + listOf( + "plain.remove deviceId durable=true", // nothing to drop, but the rule runs before any key + "secret.put box.secret durable=true", + "plain.put deviceId durable=true" + ), + journal + ) + } + + @Test + fun `an ordinary restart keeps the deviceId and the key`() { + val first = process() + val id = first.deviceId() + val box = first.boxSecret() + val again = process() + assertEquals(id, again.deviceId()) + assertContentEquals(box, again.boxSecret()) + assertEquals(1, minted) + } + + @Test + fun `a deviceId stored by an earlier build is kept while its key still opens`() { + val box = process().boxSecret() // an earlier build created the key ... + plainMem.map[PhoneIdentity.DEVICE_ID] = "legacy-id" // ... and minted its id without this class + val later = process() + assertEquals("legacy-id", later.deviceId()) + assertContentEquals(box, later.boxSecret()) + } + + @Test + fun `a transferred phone (the Keystore key stayed behind) gets a new deviceId with its new key`() { + val old = process() + val oldId = old.deviceId() + val oldBox = old.boxSecret() + // The new phone: the same preference files, a different Keystore key. The sealed box secret + // no longer authenticates, which is positive evidence it is gone. + sealer.key = GcmSealer.newKey() + val moved = process() + val newBox = moved.boxSecret() + val newId = moved.deviceId() + assertNotEquals(oldBox.toList(), newBox.toList()) + assertNotEquals(oldId, newId) + assertEquals(newId, process().deviceId(), "and the new pair is kept from then on") + } + + @Test + fun `plain preferences without the sealed ones (the key is absent) also mean a new deviceId`() { + val oldId = process().deviceId() + secretMem.map.clear() // only nodeterm.hosts came across + assertNotEquals(oldId, process().deviceId()) + } + + @Test + fun `the old deviceId is off disk before the new key is written`() { + process().deviceId() + sealer.key = GcmSealer.newKey() + journal.clear() + process().deviceId() + // A process that dies between the two writes leaves NO deviceId, never the old one beside a + // new key; the next deviceId() mints one then. + val drop = journal.indexOf("plain.remove deviceId durable=true") + val create = journal.indexOf("secret.put box.secret durable=true") + assertTrue(drop in 0 until create, "journal: $journal") + } + + @Test + fun `asking for the deviceId before the key never hands out an id that is then dropped`() { + // PairScreen evaluates deviceId before boxKeys: the key must already exist by then. + val identity = process() + val id = identity.deviceId() + identity.boxSecret() + assertEquals(id, process().deviceId()) + assertEquals(1, minted) + } + + @Test + fun `a keystore that cannot answer right now changes nothing`() { + process().deviceId() + val before = HashMap(plainMem.map) + val sealedBefore = HashMap(secretMem.map) + sealer.failOpenWith = ProviderException("Keystore operation failed") + assertFailsWith { process().deviceId() } + assertFailsWith { process().boxSecret() } + assertEquals(before, plainMem.map, "the deviceId is not dropped over a transient error") + assertEquals(sealedBefore, secretMem.map) + sealer.failOpenWith = null + assertEquals("device-1", process().deviceId()) + } + + @Test + fun `if the deviceId cannot be dropped, no new key is written`() { + process().deviceId() + sealer.key = GcmSealer.newKey() + val sealedBefore = HashMap(secretMem.map) + val failing = object : PlainStorage by plainMem { + override fun remove(name: String, durable: Boolean) = throw IllegalStateException("disk full") + } + val identity = PhoneIdentity(SecretStoreCore(secretMem, sealer), failing) { "never" } + assertFailsWith { identity.deviceId() } + assertEquals(sealedBefore, secretMem.map) + assertFalse(plainMem.map[PhoneIdentity.DEVICE_ID] == "never") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PhoneTerminalsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PhoneTerminalsTest.kt new file mode 100644 index 0000000000..581c319685 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/PhoneTerminalsTest.kt @@ -0,0 +1,76 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.ssh.HostBrowse +import dev.nodeterm.protocol.ssh.PhoneTerminals +import dev.nodeterm.protocol.ssh.SshScripts +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PhoneTerminalsTest { + private val id = "phone-12345678-1234-1234-1234-123456789abc" + private val folder = "/srv/it's a \$(touch injection) folder" + private val fingerprint = PhoneTerminals.fingerprint(id, folder) + private fun record(session: String = TmuxNames.sessionName(id), node: String = id, + creation: String = fingerprint, cwd: String = folder, marker: String = fingerprint) = + listOf(session, node, creation, cwd, marker, folder).joinToString("\t") + + @Test + fun `phone ids are reserved canonical UUIDs, and folders are shell tokens not commands`() { + assertTrue(PhoneTerminals.validId(id)) + for (invalid in listOf("term-existing", "phone-short", id.uppercase(), "$id-extra", "$id\n", "phone-../bad")) { + assertFalse(PhoneTerminals.validId(invalid), invalid) + assertFailsWith { SshScripts.createTerminal(invalid, folder) } + } + assertTrue(PhoneTerminals.validCwd(folder)) + for (invalid in listOf("relative", "~/repo", "/bad\nfolder", "/bad\t", "/bad\u0000", "/bad\u007f", "/bad\u0085", "/" + "x".repeat(4096))) { + assertFailsWith { SshScripts.createTerminal(id, invalid) } + } + assertNotEquals(fingerprint, PhoneTerminals.fingerprint(id, "/elsewhere")) + assertNotEquals(PhoneTerminals.fingerprint(id, null), PhoneTerminals.fingerprint(id, "/home")) + assertEquals(fingerprint, PhoneTerminals.fingerprint(id, folder)) + } + + @Test + fun `only complete matching owned metadata is a phone terminal`() { + assertEquals(PhoneTerminals.Entry(id, folder, fingerprint), PhoneTerminals.parse(record())) + for (bad in listOf(record(session = "nt-other"), record(node = "term-other"), record(creation = ""), + record(marker = "a".repeat(64)), record(cwd = "relative"), record(cwd = "/bad\tpath"), + record(creation = "a".repeat(64), marker = "a".repeat(64)), + record(cwd = "/bad\npath"), record() + "\textra")) { + assertNull(PhoneTerminals.parse(bad), bad) + } + } + + @Test + fun `home creation has a distinct valid atomic tuple`() { + val fp = PhoneTerminals.fingerprint(id, null) + val line = listOf(TmuxNames.sessionName(id), id, fp, "/home/test", fp, "HOME").joinToString("\t") + assertEquals(PhoneTerminals.Entry(id, "/home/test", fp), PhoneTerminals.parse(line)) + } + + @Test + fun `phone-only browse is usable without a desktop and preserves cwd across listings`() { + val raw = "${SshScripts.META_START}\nud=\n${SshScripts.META_END}\n" + + "${SshScripts.PHONE_MARK}\n${record()}\n${record()}\n${record(marker = "")}\n${SshScripts.END_MARK}\n" + val out = HostBrowse.split(raw) + assertFalse(out.nothingFound(1)) + val snapshot = HostBrowse.assemble(ProjectsSnapshot.EMPTY, out, 1) + val project = snapshot.projects.single() + assertEquals(PhoneTerminals.PROJECT_ID, project.id) + assertEquals(PhoneTerminals.PROJECT_NAME, project.name) + assertNull(project.cwd, "this is not a desktop-owned project to register nodes in") + assertEquals(id, project.nodes.single().id) + assertEquals(folder, project.nodes.single().cwd) + assertNull(project.nodes.single().agentId) + assertTrue(snapshot.isLive(id)) + assertEquals(TmuxNames.PHONE_SOCKET, snapshot.socketOf(id)) + assertNull(snapshot.statusOf(id), "a plain shell invents no managed agent status") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuestionChoicesTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuestionChoicesTest.kt new file mode 100644 index 0000000000..fc6a237854 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuestionChoicesTest.kt @@ -0,0 +1,83 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.QuestionChoices +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * A57: a multi-select AskUserQuestion card showed only "Open session", so the Inbox did not say what + * was being asked. Its options are now shown, numbered and read-only, under a line that says the + * question takes several and is answered in the session; a single-select question keeps its answer + * buttons. The rule is pure and tested here. The card itself cannot run on a JVM, so what it draws for + * each case is pinned in the app's source ([AppSourcePins]); how it looks is a device check. + */ +class QuestionChoicesTest { + private fun event( + kind: InboxKind = InboxKind.QUESTION, + options: List = listOf("Red", "Green", "Blue"), + multiSelect: Boolean = false + ) = InboxEvent( + id = "e1", ts = 1, nodeId = "term-1", agentId = "claude", sessionId = "s", kind = kind, + title = "Which colours?", detail = null, interrupted = false, resolved = false, + options = options, multiSelect = multiSelect, pendingId = null + ) + + @Test + fun `a single-select question is answered from the card, one numbered row per option`() { + assertEquals( + QuestionChoices.Answer(listOf("1. Red", "2. Green", "3. Blue")), + QuestionChoices.of(event()) + ) + } + + @Test + fun `a multi-select question shows its options read-only, numbered in the question's order`() { + assertEquals( + QuestionChoices.ReadOnly(listOf("1. Red", "2. Green", "3. Blue")), + QuestionChoices.of(event(multiSelect = true)) + ) + } + + @Test + fun `nothing is shown without options, or on a card that is not a question`() { + assertEquals(QuestionChoices.None, QuestionChoices.of(event(options = emptyList()))) + assertEquals(QuestionChoices.None, QuestionChoices.of(event(options = emptyList(), multiSelect = true))) + assertEquals(QuestionChoices.None, QuestionChoices.of(event(kind = InboxKind.APPROVAL))) + assertEquals(QuestionChoices.None, QuestionChoices.of(event(kind = InboxKind.DONE))) + } + + private val inbox get() = AppSourcePins.ui("InboxTab.kt") + + @Test + fun `the card draws its options by this rule, not by its own`() { + // The pre-fix gate (`!ev.multiSelect`) dropped a multi-select question's options entirely. + assertFalse(inbox.contains("ev.multiSelect"), "InboxTab decides by QuestionChoices.of, not by the flag") + AppSourcePins.assertInOrder(inbox, "when (val choices = QuestionChoices.of(ev))") + } + + @Test + fun `a single-select row answers through QuickActions`() { + val answer = AppSourcePins.blockAfter(inbox, "is QuestionChoices.Answer ->") + AppSourcePins.assertInOrder(answer, "choices.rows.forEachIndexed", "OutlinedButton(", "QuickActions.answerQuestion(c, ev, i)") + } + + @Test + fun `a multi-select card says it is answered in the session, shows the rows as text, and offers the session`() { + val readOnly = AppSourcePins.blockAfter(inbox, "is QuestionChoices.ReadOnly ->") + AppSourcePins.assertInOrder( + readOnly, + "QuestionChoices.SEVERAL_NOTE", + "choices.rows.forEach", + "Text(", + "TextButton(onClick = { open(ev.nodeId) })", + "Open session" + ) + // No answer path: nothing in the branch types a key or draws a button per option. + assertFalse(readOnly.contains("answerQuestion"), readOnly) + assertFalse(readOnly.contains("OutlinedButton"), readOnly) + assertFalse(readOnly.contains("clickable"), readOnly) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuickActionsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuickActionsTest.kt new file mode 100644 index 0000000000..d0cee41a98 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/QuickActionsTest.kt @@ -0,0 +1,237 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.LabelEditResult +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.NewSessionHint +import dev.nodeterm.protocol.host.QuickActions +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.AgentNodeStatus +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.MirrorInbox +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonElement +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Audit A38: which node state a quick answer requires depends on HOW it answers. + * + * The desktop publishes a concurrent (subagent) approval with its ticket while the node keeps the + * parent's WAITING badge for a held AskUserQuestion (`recordAgentEvent`, pinned by + * src/core/pending-question.test.ts "keeps the parent picker while concurrent child approvals…"). + * A ticket is answered deterministically and refused host-side once its hold ends, so the card + * decides. Keys are not: `1` typed on a node whose question picker is on screen picks option 1. + */ +class QuickActionsTest { + private val node = "term-1" + + private fun card( + id: String = "e1", + kind: InboxKind = InboxKind.APPROVAL, + pendingId: String? = "term-1-1700000000000-42", + resolved: Boolean = false, + options: List = emptyList(), + multiSelect: Boolean = false + ) = InboxEvent( + id = id, ts = 1, nodeId = node, agentId = "claude", sessionId = "s", kind = kind, + title = "Approve Bash", detail = null, interrupted = false, resolved = resolved, + options = options, multiSelect = multiSelect, pendingId = pendingId + ) + + private fun snapshot(state: AgentState?, vararg events: InboxEvent) = ProjectsSnapshot( + projects = emptyList(), liveSessions = setOf("nt-$node"), + status = AgentStatusFile( + updatedAt = 1, + nodes = mapOf(node to AgentNodeStatus(state, "claude", "s", null, false, 1, null)), + settings = null, usage = null, + inbox = MirrorInbox(events.toList(), emptyMap()), + server = null + ), + fetchedAt = 1 + ) + + /** Records every write; answers each `listProjects` from [snapshots] in turn (the last repeats). */ + private class FakeConn( + vararg snapshots: ProjectsSnapshot, + private val outcome: ApprovalOutcome = ApprovalOutcome.SENT, + answerApprovals: Boolean = true + ) : HostConnection { + private val queue = ArrayDeque(snapshots.toList()) + val answered = mutableListOf>() + val keys = mutableListOf() + + override val kind = TransportKind.RELAY + override val capabilities = HostCapabilities( + boardWrites = true, git = true, nodeActions = true, registerNode = true, answerApprovals = answerApprovals + ) + + override suspend fun listProjects(): ProjectsSnapshot = if (queue.size > 1) queue.removeFirst() else queue.first() + override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome { + answered += event.pendingId to allow + return outcome + } + override suspend fun sendKeys(nodeId: String, keys: String) { + this.keys += keys + } + + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream = + error("unused") + override suspend fun wake(nodeId: String) { error("unused") } + override suspend fun refresh(nodeId: String) { error("unused") } + override suspend fun rename(nodeId: String, title: String) { error("unused") } + override suspend fun ensureBoard(projectId: String): List? = error("unused") + override suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean = error("unused") + override suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? = error("unused") + override suspend fun registerNode(projectId: String, node: NewNode): Boolean = error("unused") + override suspend fun ackRead(nodeId: String, eventId: String?) { error("unused") } + override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? = error("unused") + override fun setOnChanged(listener: (() -> Unit)?) {} + override fun setOnClosed(listener: ((String?) -> Unit)?) {} + override fun close() {} + } + + @Test + fun `a ticketed approval on a node still WAITING on its question is answered`() = runBlocking { + val approval = card() + val question = card(id = "q1", kind = InboxKind.QUESTION, pendingId = null, options = listOf("a", "b")) + val conn = FakeConn(snapshot(AgentState.WAITING, question, approval)) + assertEquals(QuickActions.Result.SENT, QuickActions.answerApproval(conn, approval, allow = true)) + assertEquals(listOf>(approval.pendingId to true), conn.answered) + assertEquals(emptyList(), conn.keys) + } + + @Test + fun `a ticketed approval whose hold ended while the node WAITS opens the session`() = runBlocking { + // A06 on the concurrent path: "gone", and the card is still unresolved, so the prompt is on + // screen now. "Already handled" would leave the subagent waiting on nobody. + val approval = card() + val conn = FakeConn(snapshot(AgentState.WAITING, approval), outcome = ApprovalOutcome.GONE) + assertEquals(QuickActions.Result.EXPIRED, QuickActions.answerApproval(conn, approval, allow = false)) + assertEquals(emptyList(), conn.keys) + } + + @Test + fun `a gone ticket whose card the computer settled meanwhile is already handled`() = runBlocking { + val approval = card() + val conn = FakeConn( + snapshot(AgentState.WAITING, approval), + snapshot(AgentState.WAITING, approval.copy(resolved = true)), + outcome = ApprovalOutcome.GONE + ) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerApproval(conn, approval, allow = true)) + } + + @Test + fun `a resolved ticketed approval is not answered, whatever the node shows`() = runBlocking { + val approval = card() + for (state in listOf(AgentState.BLOCKED, AgentState.WAITING)) { + val conn = FakeConn(snapshot(state, approval.copy(resolved = true))) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerApproval(conn, approval, allow = true)) + assertEquals(emptyList(), conn.answered) + assertEquals(emptyList(), conn.keys) + } + } + + @Test + fun `a ticketed approval the fresh feed no longer lists needs a needs-you node`() = runBlocking { + // The host trims its feed; with no card to consult, the node's state is the evidence left. + val approval = card() + val waiting = FakeConn(snapshot(AgentState.WAITING)) + assertEquals(QuickActions.Result.SENT, QuickActions.answerApproval(waiting, approval, allow = true)) + val working = FakeConn(snapshot(AgentState.WORKING)) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerApproval(working, approval, allow = true)) + assertEquals(emptyList(), working.answered) + } + + @Test + fun `keys are not typed for a card the fresh feed no longer lists`() = runBlocking { + // The review of A25: the desktop drops events after 6 h and trims its feed to 50, keeping only + // each node's newest unresolved ask, so a card settled long ago disappears while its node + // blocks on a NEWER prompt in the same state. A key carries no identity of the prompt it + // answers: `1` here would approve the newer prompt. + val stale = card(id = "e1", pendingId = null) + val newer = card(id = "e2", pendingId = null) + val blocked = FakeConn(snapshot(AgentState.BLOCKED, newer)) + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerApproval(blocked, stale, allow = true)) + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerApproval(blocked, stale, allow = false)) + assertEquals(emptyList(), blocked.keys) + val question = card(id = "q1", kind = InboxKind.QUESTION, pendingId = null, options = listOf("yes", "no")) + val otherQuestion = card(id = "q2", kind = InboxKind.QUESTION, pendingId = null, options = listOf("a", "b")) + val waiting = FakeConn(snapshot(AgentState.WAITING, otherQuestion)) + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerQuestion(waiting, question, 0)) + // No feed at all is no evidence either. + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerQuestion(FakeConn(snapshot(AgentState.WAITING)), question, 0)) + assertEquals(emptyList(), waiting.keys) + // The node moved on: settled, whatever the feed lists. + val working = FakeConn(snapshot(AgentState.WORKING)) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerApproval(working, stale, allow = true)) + assertEquals(emptyList(), working.keys) + } + + @Test + fun `a ticketed approval this host cannot answer opens the session, never keys`() = runBlocking { + val approval = card() + val conn = FakeConn(snapshot(AgentState.WAITING, approval), answerApprovals = false) + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerApproval(conn, approval, allow = true)) + assertEquals(emptyList(), conn.keys) + } + + @Test + fun `a ticketless approval is not typed into a node WAITING on a question`() = runBlocking { + // `1` here would pick option 1 of the question on screen, not approve anything. + val approval = card(pendingId = null) + val conn = FakeConn(snapshot(AgentState.WAITING, approval)) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerApproval(conn, approval, allow = true)) + assertEquals(emptyList(), conn.keys) + assertEquals(emptyList(), conn.answered) + } + + @Test + fun `a ticketless approval on a BLOCKED node is typed`() = runBlocking { + val approval = card(pendingId = null) + val conn = FakeConn(snapshot(AgentState.BLOCKED, approval)) + assertEquals(QuickActions.Result.SENT, QuickActions.answerApproval(conn, approval, allow = true)) + assertEquals(listOf("1"), conn.keys) + } + + @Test + fun `a question is not typed into a BLOCKED node`() = runBlocking { + // The digit would answer whatever permission prompt is on screen. + val question = card(kind = InboxKind.QUESTION, pendingId = null, options = listOf("yes", "no")) + val conn = FakeConn(snapshot(AgentState.BLOCKED, question)) + assertEquals(QuickActions.Result.ALREADY_HANDLED, QuickActions.answerQuestion(conn, question, 0)) + assertEquals(emptyList(), conn.keys) + } + + @Test + fun `a question on a WAITING node is typed`() = runBlocking { + val question = card(kind = InboxKind.QUESTION, pendingId = null, options = listOf("yes", "no")) + val conn = FakeConn(snapshot(AgentState.WAITING, question)) + assertEquals(QuickActions.Result.SENT, QuickActions.answerQuestion(conn, question, 1)) + assertEquals(listOf("2"), conn.keys) + } + + @Test + fun `a multi-select question is never typed, even on a WAITING node`() = runBlocking { + // A57: its card shows the options read-only. The picker's toggle and submit keys are + // unmeasured, so a digit could submit a one-option answer (or toggle without submitting). + val question = card(kind = InboxKind.QUESTION, pendingId = null, options = listOf("a", "b", "c"), multiSelect = true) + val conn = FakeConn(snapshot(AgentState.WAITING, question)) + for (i in question.options.indices) { + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerQuestion(conn, question, i)) + } + assertEquals(emptyList(), conn.keys) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/R8RulesTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/R8RulesTest.kt new file mode 100644 index 0000000000..d40ae650c6 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/R8RulesTest.kt @@ -0,0 +1,276 @@ +package dev.nodeterm.protocol + +import java.io.ByteArrayInputStream +import java.io.DataInputStream +import java.io.File +import java.util.zip.ZipFile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Audit A37: the release build's R8 rules, `android/app/proguard-rules.pro`. + * + * R8 itself runs in CI (`:app:assembleRelease` plus `tools/check-r8-output.sh`, in + * `.github/workflows/android.yml`); AGP needs Google Maven, so it cannot run everywhere these tests + * do. This checks what a JVM can: + * + * 1. Missing classes. R8 fails the build on a class that code it compiles references and neither the + * app nor android.jar defines, unless a `-dontwarn` names the missing class or the class that + * references it. The jars the app gets through this module (sshj, BouncyCastle, EdDSA, OkHttp, + * kotlinx) are read here, every reference Android cannot resolve is collected, and each must be + * covered by the app's rules or by the consumer rules the jar itself ships. The previous rules + * missed three (sshj's GSSAPI classes and `LoginContext`, EdDSA's `X509Key`). + * 2. Keeps for code reached by NAME, which R8's tracing cannot see and a successful build does not + * check: the WebView bridge (terminal.js calls its methods by name) and the WorkManager worker + * (instantiated from the class name WorkManager stored). + * + * "Android cannot resolve" is an approximation: not defined in the scanned jars, and either absent + * from the JDK or in a package android.jar does not ship ([androidLacks]). It over-approximates R8, + * which only reports references from code it keeps. The app's own dependencies (androidx, Compose, + * zxing) are not on this classpath; CI's R8 run covers them. None of this proves a release APK works + * on a device. + */ +class R8RulesTest { + private val root = InteropHarness.repoRoot + private val rulesFile = File(root, "android/app/proguard-rules.pro") + private val appSrc = File(root, "android/app/src/main/kotlin") + + /** The runtime jars of this module, i.e. what the app ships from it (set by build.gradle.kts). */ + private val shippedJars: List by lazy { + val path = System.getProperty("nodeterm.runtimeClasspath") + ?: error("nodeterm.runtimeClasspath is not set; run through Gradle (see protocol/build.gradle.kts)") + path.split(File.pathSeparator).filter { it.endsWith(".jar") }.map(::File) + } + + private fun rulesText(): String = rulesFile.readText().replace(Regex("#[^\n]*"), "") + + /** The scan of [shippedJars]; it must see the jars, or the tests below pass on nothing. */ + private val scan: Scan by lazy { + scanShippedJars().also { assertTrue(it.classes > 1000, "only ${it.classes} classes scanned from $shippedJars") } + } + + /** + * App-rule `-dontwarn` patterns for dependencies of the app that are not on this module's classpath + * (androidx, Compose, zxing), which the scan therefore cannot see used. Empty today. + */ + private val appOnlyDontwarns = emptySet() + + // ---- 1. missing classes ---------------------------------------------------------------------- + + @Test + fun `every class Android lacks that a shipped jar references is covered by a -dontwarn`() { + val appRules = dontwarnFilters(rulesText()) + val rules = appRules + scan.consumerDontwarns + val uncovered = scan.missing.mapNotNull { (missing, contexts) -> + if (rules.any { it.matches(missing) }) return@mapNotNull null + // R8 drops a missing-class report when every class referencing it is -dontwarn'ed too. + val open = contexts.filterNot { ctx -> rules.any { it.matches(ctx) } } + if (open.isEmpty()) null else "$missing <- ${open.sorted().take(3).joinToString()}" + }.sorted() + assertEquals( + emptyList(), uncovered, + "R8 would fail the release build on these (missing class <- referenced from). Add a -dontwarn " + + "to android/app/proguard-rules.pro once you have checked the code is never reached on a phone." + ) + } + + @Test + fun `every -dontwarn in the app rules still matches a reference in the shipped jars`() { + val names = scan.missing.keys + scan.missing.values.flatten() + val unused = dontwarnFilters(rulesText()).map { it.spec }.filter { it !in appOnlyDontwarns } + .filter { spec -> ClassFilter(spec).let { f -> names.none { f.matches(it) } } } + assertEquals( + emptyList(), unused, + "these -dontwarn rules match no missing reference in the shipped jars (a stale rule hides the next real " + + "one): drop them, or, for an app-only dependency this classpath does not contain, list them in appOnlyDontwarns" + ) + } + + // ---- 2. keeps for code reached by name -------------------------------------------------------- + + @Test + fun `the WebView bridge's @JavascriptInterface methods survive R8 with their annotation`() { + val bridges = appSrc.walkTopDown().filter { it.extension == "kt" && "@JavascriptInterface" in it.readText() }.toList() + assertTrue(bridges.isNotEmpty(), "no @JavascriptInterface in the app sources; was the bridge moved?") + val rules = rulesText().replace(Regex("\\s+"), " ") + assertTrue( + Regex("""-keepclassmembers class \* \{ @android\.webkit\.JavascriptInterface ?; ?}""").containsMatchIn(rules), + "terminal.js calls the bridge's methods by name; they need a -keepclassmembers rule" + ) + assertTrue( + Regex("""-keepattributes [^-]*RuntimeVisibleAnnotations""").containsMatchIn(rules), + "Android exposes only methods still annotated @JavascriptInterface" + ) + } + + @Test + fun `every WorkManager worker keeps its name and its (Context, WorkerParameters) constructor`() { + val workers = appSrc.walkTopDown().filter { it.extension == "kt" }.flatMap { f -> + val text = f.readText() + val pkg = Regex("""(?m)^package\s+([\w.]+)""").find(text)?.groupValues?.get(1).orEmpty() + Regex("""class\s+(\w+)\s*\([^)]*\)\s*:\s*(?:CoroutineWorker|Worker|ListenableWorker)\s*\(""") + .findAll(text).map { "$pkg.${it.groupValues[1]}" } + }.toList() + assertTrue(workers.isNotEmpty(), "no worker found in the app sources; was InboxWorker moved?") + val rules = rulesText().replace(Regex("\\s+"), " ") + for (w in workers) { + // `-keep class` with no allowobfuscation: WorkManager stores the class name across releases. + val keep = Regex( + "-keep class " + Regex.escape(w) + + """ \{[^}]*public ?\( ?android\.content\.Context ?, ?androidx\.work\.WorkerParameters ?\) ?;[^}]*}""" + ) + assertTrue(keep.containsMatchIn(rules), "$w needs `-keep class $w { public (android.content.Context, androidx.work.WorkerParameters); }`") + } + } + + @Test + fun `exception class names survive where error text falls back to them`() { + val sources = listOf(appSrc, File(root, "android/protocol/src/main/kotlin")) + val users = sources.flatMap { dir -> dir.walkTopDown().filter { it.extension == "kt" && "javaClass.simpleName" in it.readText() }.toList() } + if (users.isEmpty()) return + assertTrue( + Regex("""-keepnames class \* extends java\.lang\.Throwable""").containsMatchIn(rulesText().replace(Regex("\\s+"), " ")), + "${users.map { it.name }} show e.javaClass.simpleName to the user; obfuscated it reads \"a\"" + ) + } + + @Test + fun `the release build runs R8 and CI builds it, while debug stays unminified`() { + val gradle = File(root, "android/app/build.gradle.kts").readText() + val release = AppSourcePins.blockAfter(gradle, "release {") + val debug = AppSourcePins.blockAfter(gradle, "debug {") + assertTrue(Regex("""isMinifyEnabled\s*=\s*true""").containsMatchIn(release), "release must be minified:\n$release") + assertTrue("proguard-rules.pro" in release, "release must use proguard-rules.pro:\n$release") + assertTrue(!Regex("""isMinifyEnabled\s*=\s*true""").containsMatchIn(debug), "debug is the distributed APK and stays unminified") + val ci = File(root, ".github/workflows/android.yml").readText() + assertTrue(":app:assembleRelease" in ci, "CI must build the release APK so R8 runs on every change") + assertTrue("tools/check-r8-output.sh" in ci, "CI must check the R8 keeps matched") + } + + // ---- the scan --------------------------------------------------------------------------------- + + private class Scan(val classes: Int, val missing: Map>, val consumerDontwarns: List) + + /** + * Packages the JDK has and android.jar (API 35) does not. Only what a shipped jar could plausibly + * reference; the list decides nothing for a class that no jar references. + */ + private val androidLacks = listOf( + "sun.", "com.sun.", "jdk.", "org.ietf.jgss.", "javax.naming.", "java.lang.instrument.", + "java.lang.management.", "javax.management.", "java.awt.", "javax.swing.", "javax.imageio.", + "java.rmi.", "javax.rmi.", "javax.security.auth.kerberos.", "java.net.http.", "javax.script.", + "javax.tools.", "javax.lang.model.", "javax.annotation.processing." + ) + + private fun androidLacks(name: String): Boolean = + androidLacks.any { name.startsWith(it) } || + // android.jar's javax.security.auth.login holds LoginException and nothing else. + (name.startsWith("javax.security.auth.login.") && name != "javax.security.auth.login.LoginException") + + private fun inJdk(name: String): Boolean = + ClassLoader.getPlatformClassLoader().getResource(name.replace('.', '/') + ".class") != null + + private fun scanShippedJars(): Scan { + val defined = HashSet() + val refs = HashMap>() // referenced class -> classes referencing it + val consumer = ArrayList() + var count = 0 + for (jar in shippedJars) ZipFile(jar).use { zip -> + for (e in zip.entries()) { + val n = e.name + if (n.endsWith(".pro") && (n.startsWith("META-INF/proguard/") || n.startsWith("META-INF/com.android.tools/"))) { + consumer += dontwarnFilters(zip.getInputStream(e).readBytes().decodeToString().replace(Regex("#[^\n]*"), "")) + } + // D8/R8 ignore everything under META-INF (multi-release versions included) and module-info. + if (!n.endsWith(".class") || n.startsWith("META-INF/") || n.endsWith("module-info.class")) continue + val (self, used) = classReferences(zip.getInputStream(e).readBytes()) + defined += self + count++ + for (r in used) if (r != self) refs.getOrPut(r) { HashSet() } += self + } + } + val missing = refs.filterKeys { r -> + r !in defined && !r.startsWith("android.") && !r.startsWith("dalvik.") && (!inJdk(r) || androidLacks(r)) + } + return Scan(count, missing, consumer) + } + + /** This class's name and every class its constant pool and member descriptors name. */ + private fun classReferences(bytes: ByteArray): Pair> { + val input = DataInputStream(ByteArrayInputStream(bytes)) + require(input.readInt() == 0xCAFEBABE.toInt()) { "not a class file" } + input.readUnsignedShort(); input.readUnsignedShort() + val n = input.readUnsignedShort() + val utf8 = arrayOfNulls(n) + val classNameAt = IntArray(n) // CONSTANT_Class #i -> the Utf8 holding its name + val descIdx = ArrayList() + var i = 1 + while (i < n) { + when (val tag = input.readUnsignedByte()) { + 1 -> utf8[i] = input.readUTF() + 3, 4 -> input.readInt() + 5, 6 -> { input.readLong(); i++ } // takes two slots + 7 -> classNameAt[i] = input.readUnsignedShort() + 8, 19, 20 -> input.readUnsignedShort() + 16 -> descIdx += input.readUnsignedShort() // MethodType + 9, 10, 11, 17, 18 -> { input.readUnsignedShort(); input.readUnsignedShort() } + 12 -> { input.readUnsignedShort(); descIdx += input.readUnsignedShort() } // NameAndType + 15 -> { input.readUnsignedByte(); input.readUnsignedShort() } + else -> error("constant pool tag $tag") + } + i++ + } + input.readUnsignedShort() // access + val self = utf8[classNameAt[input.readUnsignedShort()]] ?: error("no this_class") + input.readUnsignedShort() // super_class, a CONSTANT_Class like any other + repeat(input.readUnsignedShort()) { input.readUnsignedShort() } + repeat(2) { // fields, then methods: their descriptors name types no instruction may touch + repeat(input.readUnsignedShort()) { + input.readUnsignedShort(); input.readUnsignedShort() + descIdx += input.readUnsignedShort() + repeat(input.readUnsignedShort()) { input.readUnsignedShort(); input.skipBytes(input.readInt()) } + } + } + val names = HashSet() + val inDescriptor = Regex("L([^;]+);") + for (idx in classNameAt) { + val s = if (idx == 0) continue else utf8[idx] ?: continue + if (s.startsWith("[")) inDescriptor.findAll(s).forEach { names += it.groupValues[1] } else names += s + } + for (idx in descIdx) utf8[idx]?.let { d -> inDescriptor.findAll(d).forEach { names += it.groupValues[1] } } + return self.replace('/', '.') to names.map { it.replace('/', '.') }.toSet() + } + + // ---- ProGuard class filters ------------------------------------------------------------------- + + /** A `-dontwarn` argument: a comma list of `?`/`*`/`**` patterns, each optionally negated with `!`. */ + private class ClassFilter(val spec: String) { + private val parts = spec.split(',').map { it.trim() }.filter { it.isNotEmpty() }.ifEmpty { listOf("**") }.map { p -> + val negated = p.startsWith("!") + negated to Regex(glob(p.removePrefix("!"))) + } + + fun matches(name: String): Boolean { + for ((negated, re) in parts) if (re.matches(name)) return !negated + return false + } + + private fun glob(g: String): String = buildString { + var i = 0 + while (i < g.length) { + when { + g.startsWith("**", i) -> { append(".*"); i += 2; continue } + g[i] == '*' -> append("[^.]*") + g[i] == '?' -> append("[^.]") + else -> append(Regex.escape(g[i].toString())) + } + i++ + } + } + } + + private fun dontwarnFilters(rules: String): List = + rules.lines().map { it.trim() }.filter { it.startsWith("-dontwarn") } + .map { ClassFilter(it.removePrefix("-dontwarn").trim()) } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayApiTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayApiTest.kt new file mode 100644 index 0000000000..b2feae42b1 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayApiTest.kt @@ -0,0 +1,237 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.relay.OkHttpRelayTransport +import dev.nodeterm.protocol.relay.RelayApi +import dev.nodeterm.protocol.relay.RelayApiException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.Dns +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.InetAddress +import java.net.Proxy +import java.net.Socket +import java.net.SocketAddress +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import javax.net.SocketFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** Real OkHttp HTTP/1 exchanges over in-memory sockets: no localhost listener or network required. */ +class RelayApiTest { + private enum class Reply { SILENT_HEADERS, SILENT_BODY, TRICKLING_BODY, COMPLETE } + + /** A connected socket whose read can stall until OkHttp's timeout or cancellation closes it. */ + private class MemorySocket(reply: Reply, body: String) : Socket() { + val waiting = CountDownLatch(1) + val closed = CountDownLatch(1) + val request = ByteArrayOutputStream() + private val gate = Object() + @Volatile private var ended = false + private var socketTimeout = 0 + private val prefix = when (reply) { + Reply.SILENT_HEADERS -> byteArrayOf() + Reply.COMPLETE -> ("HTTP/1.1 200 OK\r\nContent-Length: ${body.toByteArray().size}\r\nConnection: close\r\n\r\n" + body).toByteArray() + else -> "HTTP/1.1 200 OK\r\nContent-Length: 100000\r\nConnection: close\r\n\r\n".toByteArray() + } + private var offset = 0 + private val input = object : InputStream() { + override fun read(): Int { + val byte = ByteArray(1) + return if (read(byte, 0, 1) < 0) -1 else byte[0].toInt() and 0xff + } + + override fun read(b: ByteArray, off: Int, len: Int): Int = synchronized(gate) { + if (ended) throw IOException("socket closed") + if (offset < prefix.size) { + val n = minOf(len, prefix.size - offset) + prefix.copyInto(b, off, offset, offset + n) + offset += n + return@synchronized n + } + if (reply == Reply.COMPLETE) return@synchronized -1 + waiting.countDown() + while (!ended) { + gate.wait(if (reply == Reply.TRICKLING_BODY) 30L else 0L) + if (!ended && reply == Reply.TRICKLING_BODY) { + b[off] = ' '.code.toByte() + return@synchronized 1 + } + } + throw IOException("socket closed") + } + } + + override fun connect(endpoint: SocketAddress, timeout: Int) { /* connected entirely in memory */ } + override fun getInputStream(): InputStream = input + override fun getOutputStream(): ByteArrayOutputStream = request + override fun setSoTimeout(timeout: Int) { socketTimeout = timeout } + override fun getSoTimeout(): Int = socketTimeout + override fun isConnected(): Boolean = true + override fun isClosed(): Boolean = ended + override fun close() { + synchronized(gate) { + ended = true + closed.countDown() + gate.notifyAll() + } + } + } + + private class Exchange(reply: Reply, body: String = "") : AutoCloseable { + val socket = MemorySocket(reply, body) + val client: OkHttpClient = OkHttpClient.Builder() + .proxy(Proxy.NO_PROXY) + .dns(object : Dns { + override fun lookup(hostname: String): List = + listOf(InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1))) + }) + .socketFactory(object : SocketFactory() { + override fun createSocket(): Socket = socket + override fun createSocket(host: String, port: Int): Socket = error("unexpected connect overload") + override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket = error("unexpected connect overload") + override fun createSocket(host: InetAddress, port: Int): Socket = error("unexpected connect overload") + override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket = error("unexpected connect overload") + }) + // Reproduce the old WebSocket-derived HTTP client: neither timeout rescues the call. + .readTimeout(0, TimeUnit.MILLISECONDS) + .callTimeout(0, TimeUnit.MILLISECONDS) + .build() + + fun api(deadlineMs: Long = 150) = RelayApi("http://memory.test", client, deadlineMs) + + override fun close() { + socket.close() + client.connectionPool.evictAll() + client.dispatcher.executorService.shutdownNow() + } + } + + @Test + fun `HTTP uses finite read and whole call limits while the WebSocket stays long lived`() { + assertTrue(RelayApi.defaultHttpClient.readTimeoutMillis > 0) + assertTrue(RelayApi.defaultHttpClient.callTimeoutMillis > 0) + assertEquals(0, OkHttpRelayTransport.defaultClient.readTimeoutMillis) + assertFailsWith { RelayApi(deadlineMs = 0) } + } + + @Test + fun `join and device mint stop when headers body or a trickling body exceed the deadline`() = runBlocking { + for (reply in listOf(Reply.SILENT_HEADERS, Reply.SILENT_BODY, Reply.TRICKLING_BODY)) { + for (mint in listOf(false, true)) { + Exchange(reply).use { exchange -> + val failure = assertFailsWith { + // Also bounds the test if the HTTP deadline is removed: cancellation then + // closes the in-memory socket, and a timeout assertion fails instead of hangs. + withTimeout(3_000) { + if (mint) exchange.api().mintDevice("phone", "host-device", "host-key", "Phone", null) + else exchange.api().join("device-token", "host-id") + } + } + assertTrue(failure.message!!.startsWith("Couldn't reach the relay:"), failure.message) + assertEquals(0L, exchange.socket.closed.count, "the deadline must release the stalled socket") + } + } + } + } + + @Test + fun `cancelling a join or device mint closes a socket stalled in headers or the body`() = runBlocking { + for (reply in listOf(Reply.SILENT_HEADERS, Reply.SILENT_BODY)) for (mint in listOf(false, true)) { + Exchange(reply).use { exchange -> + val request = async { + if (mint) exchange.api(10_000).mintDevice("phone", "host-device", "host-key", "Phone", null) + else exchange.api(10_000).join("device-token", "host-id") + } + // Let the child run before waiting on the socket's callback thread. + kotlinx.coroutines.yield() + assertTrue(exchange.socket.waiting.await(2, TimeUnit.SECONDS), "the request must reach the stalled read") + request.cancel() + withTimeout(1_000) { request.join() } + assertTrue(request.isCancelled) + assertTrue(exchange.socket.closed.await(1, TimeUnit.SECONDS), "coroutine cancellation must cancel OkHttp's call") + } + } + } + + @Test + fun `join and device mint deadlines include waiting for a busy dispatcher`() = runBlocking { + for (mint in listOf(false, true)) { + Exchange(Reply.SILENT_HEADERS).use { exchange -> + exchange.client.dispatcher.maxRequests = 1 + val blocker = exchange.client.newCall(Request.Builder().url("http://memory.test/blocker").build()) + val blockerFinished = CountDownLatch(1) + blocker.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { blockerFinished.countDown() } + override fun onResponse(call: Call, response: Response) { + response.close() + blockerFinished.countDown() + } + }) + try { + assertTrue(exchange.socket.waiting.await(2, TimeUnit.SECONDS), "the blocker must occupy the dispatcher") + val failure = assertFailsWith { + withTimeout(3_000) { + if (mint) exchange.api().mintDevice("phone", "host-device", "host-key", "Phone", null) + else exchange.api().join("device-token", "host-id") + } + } + assertTrue(failure.message!!.startsWith("Couldn't reach the relay:"), failure.message) + val queued = exchange.client.dispatcher.queuedCalls().single() + assertTrue(queued.isCanceled(), "the expired queued request must be cancelled before it can run") + assertEquals(1, exchange.client.dispatcher.runningCallsCount(), "the blocker must still occupy the dispatcher") + } finally { + blocker.cancel() + assertTrue(blockerFinished.await(1, TimeUnit.SECONDS), "the blocker must release the dispatcher") + } + } + } + } + + @Test + fun `a caller's shorter deadline remains coroutine cancellation`() = runBlocking { + for (mint in listOf(false, true)) { + Exchange(Reply.SILENT_HEADERS).use { exchange -> + assertFailsWith { + withTimeout(150) { + if (mint) exchange.api(10_000).mintDevice("phone", "host-device", "host-key", "Phone", null) + else exchange.api(10_000).join("device-token", "host-id") + } + } + assertTrue(exchange.socket.closed.await(1, TimeUnit.SECONDS), "the caller's deadline must cancel the active call") + } + } + } + + @Test + fun `join and device mint keep their request and response shapes`() = runBlocking { + Exchange(Reply.COMPLETE, """{"pairingToken":"join-token","exp":123}""").use { exchange -> + val result = exchange.api(3_000).join("device-token", "host-id") + assertEquals("join-token", result.pairingToken) + assertEquals(123L, result.exp) + val request = exchange.socket.request.toString(Charsets.UTF_8.name()) + assertTrue(request.startsWith("POST /v1/relay/join HTTP/1.1\r\n"), request) + assertTrue(request.endsWith("""{"deviceToken":"device-token","hostId":"host-id"}"""), request) + } + Exchange(Reply.COMPLETE, """{"deviceToken":"minted","hostId":"host-id","exp":456}""").use { exchange -> + val result = exchange.api(3_000).mintDevice("phone", "host-device", "host-key", "Phone", "prior") + assertEquals("minted", result.deviceToken) + assertEquals("host-id", result.hostId) + assertEquals(456L, result.exp) + val request = exchange.socket.request.toString(Charsets.UTF_8.name()) + assertTrue(request.startsWith("POST /v1/relay/device HTTP/1.1\r\n"), request) + assertTrue(request.endsWith("""{"deviceId":"phone","hostDeviceId":"host-device","hostPublicKeyB64":"host-key","label":"Phone","priorDeviceToken":"prior"}"""), request) + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayApprovalGateTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayApprovalGateTest.kt new file mode 100644 index 0000000000..0cdd28ce7f --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayApprovalGateTest.kt @@ -0,0 +1,70 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.RelayApprovalGate +import dev.nodeterm.protocol.host.RelayApprovalGate.Decision +import dev.nodeterm.protocol.host.RelayApprovalGate.Trigger +import dev.nodeterm.protocol.host.RelayApprovalRefusedException +import dev.nodeterm.protocol.host.RelayApprovalRequiredException +import dev.nodeterm.protocol.host.RelayApprovalTimeoutException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Audit A05/A17/A23/A30: who may dial the relay, and when. */ +class RelayApprovalGateTest { + private val approved = HashMap() + private val gate = RelayApprovalGate({ approved[it] == true }, { id, v -> approved[id] = v }) + + @Test + fun `the background worker never makes a first relay handshake`() { + assertIs(gate.decide("h", Trigger.BACKGROUND)) + // The foreground may: that is where the code is shown. + assertEquals(Decision.Dial(requireApproved = false), gate.decide("h", Trigger.AUTO)) + assertEquals(Decision.Dial(requireApproved = false), gate.decide("h", Trigger.USER)) + } + + @Test + fun `after a foreground connect succeeds the worker may dial, but never waits for approval`() { + gate.onConnected("h") + assertTrue(approved["h"]!!) + assertEquals(Decision.Dial(requireApproved = true), gate.decide("h", Trigger.BACKGROUND)) + } + + @Test + fun `a revoked pin found in the background is forgotten so the worker stops trying`() { + gate.onConnected("h") + gate.onFailed("h", RelayApprovalRequiredException()) + assertFalse(approved["h"]!!) + assertIs(gate.decide("h", Trigger.BACKGROUND)) + } + + @Test + fun `Deny holds automatic dials until the user asks again`() { + gate.onFailed("h", RelayApprovalRefusedException()) + val auto = gate.decide("h", Trigger.AUTO) + assertIs(auto) + assertTrue(auto.reason.contains("declined")) + assertIs(gate.decide("h", Trigger.BACKGROUND)) + // Only the user releases it. + assertEquals(Decision.Dial(requireApproved = false), gate.decide("h", Trigger.USER)) + assertNull(gate.hold("h")) + assertEquals(Decision.Dial(requireApproved = false), gate.decide("h", Trigger.AUTO)) + } + + @Test + fun `an unanswered approval holds too, and holds are per computer`() { + gate.onFailed("h", RelayApprovalTimeoutException()) + assertIs(gate.decide("h", Trigger.AUTO)) + assertEquals(Decision.Dial(requireApproved = false), gate.decide("other", Trigger.AUTO)) + } + + @Test + fun `ordinary failures are retried as before`() { + gate.onFailed("h", HostException("relay unreachable")) + assertEquals(Decision.Dial(requireApproved = false), gate.decide("h", Trigger.AUTO)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt new file mode 100644 index 0000000000..3fb77b6cb3 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayInteropTest.kt @@ -0,0 +1,1193 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.git.GitConflict +import dev.nodeterm.protocol.git.GitDiff +import dev.nodeterm.protocol.git.GitFileChange +import dev.nodeterm.protocol.git.GitResult +import dev.nodeterm.protocol.git.SourceControl +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.QuickActions +import dev.nodeterm.protocol.host.RelayConnectStatus +import dev.nodeterm.protocol.host.RelayConnector +import dev.nodeterm.protocol.host.RelayApprovalRefusedException +import dev.nodeterm.protocol.host.RelayApprovalRequiredException +import dev.nodeterm.protocol.host.RelayApprovalTimeoutException +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalScrollView +import dev.nodeterm.protocol.host.ComposedInput +import dev.nodeterm.protocol.host.ComposedInputResult +import dev.nodeterm.protocol.model.AccountNames +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.NodeKind +import dev.nodeterm.protocol.model.SessionBucket +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.pairing.SshIdentity +import dev.nodeterm.protocol.ssh.HostKeyChangedException +import dev.nodeterm.protocol.ssh.HostKeyPin +import dev.nodeterm.protocol.ssh.LanRefresh +import dev.nodeterm.protocol.ssh.LanReport +import dev.nodeterm.protocol.ssh.SshFallback +import dev.nodeterm.protocol.ssh.SshHostConnection +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.KeyType +import org.apache.sshd.server.SshServer +import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.async +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.double +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.jsonArray +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Kotlin relay client against the DESKTOP's real host code: `connectHostSession` + + * `createHostHandlers` (host-service.ts) over `connectRelay` (relay-socket.ts, host role), through a + * local broker. Everything the phone does over the relay goes across this wire at least once. The + * `projects.list` blob it parses is the desktop's own (`buildProjectsListBlob` over a real + * `WorkspaceStore` and a mirror file the real agent-status mirror wrote, audit A64); the pty, + * kanban, inbox and node-action bridges behind the verbs are fakes that record what was asked. The + * `git.*` verbs are not: they run the desktop's real `GitService` over a repository in the project's + * folder (audit A29). + */ +class RelayInteropTest { + private val harnesses = ArrayList() + private val userDataDirs = ArrayList() + private val sshServers = ArrayList() + + @AfterTest + fun tearDown() { + harnesses.forEach { it.close() } + sshServers.forEach { it.stop(true) } + userDataDirs.forEach { it.deleteRecursively() } + } + + /** + * The fixture's userData is a scratch dir per harness: relay mode writes the desktop's workspace + * (a v3 index and the project's `.nodeterm/project.json`) and its agent-status mirror there, and + * serves `projects.list` from them through the desktop's own assembly (audit A64). + */ + private fun start(approveAfterMs: Long = 0, rejectAfterMs: Long = -1, extra: Map = emptyMap()): InteropHarness { + val userData = Files.createTempDirectory("nt-relay-ud").toFile().also { userDataDirs += it } + return InteropHarness.start( + "relay", + mapOf( + "FIXTURE_APPROVE_AFTER_MS" to approveAfterMs.toString(), + "FIXTURE_REJECT_AFTER_MS" to rejectAfterMs.toString(), + "FIXTURE_USERDATA" to userData.path + ) + extra + ).also { harnesses += it } + } + + private fun startLaunch(trusted: Boolean): InteropHarness { + val userData = Files.createTempDirectory("nt-launch-ud").toFile().also { userDataDirs += it } + return InteropHarness.start("project-launch", mapOf("FIXTURE_USERDATA" to userData.path, + "SHELL" to "/fixture/a72-default", + "FIXTURE_LAUNCH_TRUSTED" to if (trusted) "1" else "0")).also { harnesses += it } + } + + private fun InteropHarness.str(key: String) = ready[key]!!.jsonPrimitive.content + private fun JsonObject.str(key: String) = this[key]!!.jsonPrimitive.content + + private fun connect( + h: InteropHarness, + keys: BoxKeyPair = BoxKeyPair.generate(), + statuses: MutableList? = null, + approvalTimeoutMs: Long = 20_000, + requireApproved: Boolean = false + ) = + runBlocking { + RelayConnector.connect( + requireApproved = requireApproved, + relayUrl = h.str("relayUrl"), + token = h.str("clientToken"), + deviceKeys = keys, + hostPublicKeyB64 = h.str("hostPublicKeyB64"), + onStatus = { statuses?.add(it) }, + approvalTimeoutMs = approvalTimeoutMs, + approvalPollMs = 200 + ) + } + + private class RecordingSink : TerminalSink { + val paints = CopyOnWriteArrayList() + val output = ByteArrayOutputStream() + val resized = CopyOnWriteArrayList>() + val exited = CountDownLatch(1) + @Volatile var exitCode: Int? = null + + override fun onPaint(text: String) { + paints += text + } + + override fun onOutput(bytes: ByteArray) { + synchronized(output) { output.write(bytes) } + } + + override fun onResized(cols: Int, rows: Int) { + resized += cols to rows + } + + override fun onExit(code: Int?) { + exitCode = code + exited.countDown() + } + + fun text(): String = synchronized(output) { output.toString(Charsets.UTF_8) } + + fun awaitText(needle: String, timeoutMs: Long = 5_000) { + val deadline = System.currentTimeMillis() + timeoutMs + while (!text().contains(needle)) { + if (System.currentTimeMillis() > deadline) throw AssertionError("'$needle' not in output: ${text()}") + Thread.sleep(20) + } + } + } + + @Test + fun `handshake, approval wait and SAS agree with the desktop`() { + val h = start(approveAfterMs = 700) + val keys = BoxKeyPair.generate() + val statuses = ArrayList() + val connected = connect(h, keys, statuses) + connected.connection.use { + val peer = h.awaitEvent("peer-ready") + assertEquals(peer.str("sas"), connected.sas, "both ends must show the same code") + assertEquals(keys.publicKeyB64, peer.str("pub"), "the host pins exactly our box key") + assertTrue(statuses.any { it is RelayConnectStatus.AwaitingApproval }, "the wait was surfaced: $statuses") + h.awaitEvent("approved") + } + } + + @Test + fun `an unapproved phone is told so and never served`() { + val h = start(approveAfterMs = -1) + val e = assertFailsWith { connect(h, approvalTimeoutMs = 1_200) } + assertTrue(e.message!!.contains("approved"), e.message) + } + + @Test + fun `pressing Deny on the desktop reads as a refusal, not a network error`() { + // A30: the refusal has to be recognisable, or the phone re-dials and the dialog comes back. + val h = start(approveAfterMs = -1, rejectAfterMs = 600) + val statuses = ArrayList() + assertFailsWith { connect(h, statuses = statuses, approvalTimeoutMs = 10_000) } + assertTrue(statuses.any { it is RelayConnectStatus.AwaitingApproval }) + h.awaitEvent("rejected") + } + + @Test + fun `a background dial gives up instead of waiting on an approval dialog`() { + val h = start(approveAfterMs = -1) + val statuses = ArrayList() + val t0 = System.currentTimeMillis() + assertFailsWith { + connect(h, statuses = statuses, approvalTimeoutMs = 20_000, requireApproved = true) + } + assertTrue(System.currentTimeMillis() - t0 < 10_000, "it did not wait out the approval window") + assertTrue(statuses.none { it is RelayConnectStatus.AwaitingApproval }, "no code shown for a dial nobody watches") + } + + @Test + fun `a background dial is not turned away while the computer approves it on its own`() { + // Review of A07-late: the standing host decides silently whether a phone is approved (its pin + // store, and for a paired phone whose key pairing recorded, the late pin, which writes it), + // and the phone's first request can arrive while that is still on disk. The desktop holds it + // until the decision instead of answering "Awaiting host approval.", which a background check + // reads as "a human must approve this phone": it gives up and forgets it was approved. + val h = start(approveAfterMs = -1, extra = mapOf("FIXTURE_DECIDE_AFTER_MS" to "600", "FIXTURE_DECIDE" to "approve")) + val statuses = ArrayList() + val connected = connect(h, statuses = statuses, requireApproved = true) + connected.connection.use { + assertTrue(connected.first.projects.isNotEmpty(), "served the listing it asked for while the host decided") + assertTrue(statuses.none { it is RelayConnectStatus.AwaitingApproval }, "no code shown: $statuses") + assertTrue(h.awaitEvent("decided")["approved"]!!.jsonPrimitive.content.toBoolean()) + h.awaitEvent("approved") + } + } + + @Test + fun `a background dial still gives up when the computer's decision is to ask the human`() { + // The hold ends with the decision: a phone the host hands to its dialog hears "Awaiting host + // approval." then, exactly as before, and a background dial does not wait on the human. + val decideMs = 600L + val h = start(approveAfterMs = -1, extra = mapOf("FIXTURE_DECIDE_AFTER_MS" to decideMs.toString(), "FIXTURE_DECIDE" to "ask")) + val t0 = System.currentTimeMillis() + assertFailsWith { connect(h, requireApproved = true) } + val elapsed = System.currentTimeMillis() - t0 + assertFalse(h.awaitEvent("decided")["approved"]!!.jsonPrimitive.content.toBoolean()) + assertTrue(elapsed >= decideMs - 100, "answered only once the host had decided (after ${elapsed} ms)") + assertTrue(elapsed < 10_000, "it did not wait on the human") + } + + @Test + fun `projects list parses the desktop blob`() { + val h = start() + val connected = connect(h) + connected.connection.use { + val snap = connected.first + val p = snap.projects.single() + assertEquals("Demo", p.name) + assertEquals(listOf("term-abc-1"), p.sessions.map { it.id }) + assertEquals(NodeKind.STICKY, p.nodes[1].kind) + assertTrue(snap.isLive("term-abc-1")) + val st = snap.statusOf("term-abc-1")!! + assertEquals(AgentState.BLOCKED, st.state) + assertEquals(SessionBucket.NEEDS_YOU, st.bucket) + assertEquals("fix bug", st.name) + // A39/A75: the account is named by the label the desktop's own builder wrote, never its id. + assertEquals("Work", AccountNames.observed(st.account, snap.status)) + assertEquals("Work", AccountNames.managed("acct-1", snap.status)) + assertEquals("me@work.example", snap.status!!.settings!!.claudeAccounts.single().email) + val ev = snap.status!!.inbox!!.events.single() + assertEquals(InboxKind.APPROVAL, ev.kind) + assertEquals("term-abc-1-1700000000000-42", ev.pendingId) + // The mirror's own summary of the held Bash call (A64: nothing here is hand-written). + assertEquals("Run command", ev.title) + assertEquals("npm test", ev.detail) + assertEquals("s-1", ev.sessionId) + assertFalse(ev.resolved) + assertEquals("Running npm test", snap.status!!.inbox!!.nodes["term-abc-1"]!!.activity) + val board = p.board!! + assertEquals("c1", board.columnOf("term-abc-1")) + assertEquals(listOf("l1"), board.metaOf("term-abc-1")!!.labels) + } + } + + @Test + fun `phone terminal route fallback refuses before creating any desktop session`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val e = assertFailsWith { + conn.attach("phone-12345678-1234-1234-1234-123456789abc", 80, 24, RecordingSink()) + } + assertTrue(e.message!!.contains("SSH")) + // Flush the fixture via a real later RPC, then inspect its application event stream. + conn.listProjects() + assertFailsWith { h.awaitEvent("attach", 250) } + } + } + + @Test + fun `attach paints the snapshot, streams output and carries input`() = runBlocking { + val h = start() + val conn = connect(h).connection + conn.use { + val sink = RecordingSink() + val stream = conn.attach("term-abc-1", 90, 30, sink) + assertFalse(stream.fresh, "a live session is a warm join") + val attach = h.awaitEvent("attach") + assertEquals(90, attach.str("cols").toInt()) + assertEquals("false", attach.str("adaptsToSize"), "a phone is a size CEILING, like iOS") + sink.awaitText("hello term-abc-1") + assertEquals(listOf("screen of term-abc-1"), sink.paints) + stream.write("ls -la\r") + assertEquals("ls -la\r", h.awaitEvent("write").str("data")) + sink.awaitText("echo:ls -la") + stream.write("ünïcødé ✓\r") + sink.awaitText("echo:ünïcødé ✓") + } + } + + @Test + fun `an attach cancelled while its request is on the wire leaves no viewer on the host`() = runBlocking { + // A40 review: the host reserves the stream (a viewer on the node: an Eco shield and a size + // ceiling on the desktop) before it replies. A caller that gave up meanwhile never gets the + // stream, so the transport must let it go, or nothing ever does. + val h = start() + connect(h).connection.use { conn -> + val job = launch(Dispatchers.Default) { conn.attach("term-slow-1", 80, 24, RecordingSink()) } + assertEquals("term-slow-1", h.awaitEvent("probe").str("persistKey"), "the request reached the host") + assertEquals("term-slow-1", h.awaitEvent("viewer-attached").str("nodeId")) + job.cancelAndJoin() + assertTrue(job.isCancelled) + assertEquals("term-slow-1", h.awaitEvent("viewer-detached", 5_000).str("nodeId"), "the stream was let go of") + // The connection is still good for the next attach. + conn.attach("term-abc-1", 80, 24, RecordingSink()).detach() + } + } + + @Test + fun `a multi-chunk snapshot survives a code point split across chunks`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val sink = RecordingSink() + conn.attach("term-big-1", 80, 24, sink) + sink.awaitText("hello term-big-1") + assertEquals("SNAP-" + "€".repeat(100_000) + "-END", sink.paints.single()) + } + } + + @Test + fun `fresh reports a cold start, and resize round-trips a Resized frame`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val sink = RecordingSink() + val stream = conn.attach("term-new-1", 80, 24, sink) + assertTrue(stream.fresh) + sink.awaitText("hello term-new-1") + stream.resize(100, 30) + val r = h.awaitEvent("resize") + assertEquals(100, r.str("cols").toInt()) + assertEquals(30, r.str("rows").toInt()) + val deadline = System.currentTimeMillis() + 3_000 + while (sink.resized.isEmpty() && System.currentTimeMillis() < deadline) Thread.sleep(20) + assertEquals(listOf(132 to 43), sink.resized.toList()) + } + } + + @Test + fun `the pane exiting reaches the sink with its code`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val sink = RecordingSink() + val stream = conn.attach("term-abc-1", 80, 24, sink) + sink.awaitText("hello") + stream.write("exit\r") + assertTrue(sink.exited.await(5, TimeUnit.SECONDS)) + assertEquals(7, sink.exitCode) + } + } + + @Test + fun `detach, end session, scroll and node actions reach the host`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val a = conn.attach("term-abc-1", 80, 24, RecordingSink()) + a.scroll(up = true, lines = 3) + repeat(3) { assertEquals("\u001b[<64;1;1M", h.awaitEvent("write").str("data")) } + a.detach() + h.awaitEvent("kill") + + val b = conn.attach("term-new-1", 80, 24, RecordingSink()) + b.endSession() + assertEquals("term-new-1", h.awaitEvent("destroyNode").str("nodeId")) + + conn.wake("term-abc-1") + assertEquals("term-abc-1", h.awaitEvent("wake").str("nodeId")) + conn.refresh("term-abc-1") + h.awaitEvent("refresh") + conn.rename("term-abc-1", "new\u001b[31m name") + assertEquals("new [31m name", h.awaitEvent("rename").str("title"), "control chars are stripped host-side") + } + } + + @Test + fun `history search reaches the attached desktop session and returns older retained output`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val sink = RecordingSink() + val stream = conn.attach("term-abc-1", 80, 24, sink) + sink.awaitText("hello") + val result = stream.searchHistory(".* Ω 😀") + assertEquals(501, result.searchedLines) + assertFalse(result.truncated) + assertEquals(listOf(dev.nodeterm.protocol.model.TerminalHistory.Row(0, "old.* Ω 😀")), result.rows) + val request = h.awaitEvent("historySearch") + assertEquals("sess-1", request.str("sessionId")) + assertEquals(".* Ω 😀", request.str("query")) + stream.detach() + assertFailsWith { stream.searchHistory("old") } + } + } + + @Test fun `composed Send uses the real attached-stream host RPC and normalized full paste`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val stream = conn.attach("term-abc-1", 80, 24, RecordingSink()) + val text = "a full prompt exceeding sixteen characters\nΩ 😀\u001b" + assertEquals(ComposedInputResult.DELIVERED, stream.submitComposed(ComposedInput.Paste(text, true))) + val event = h.awaitEvent("submitComposed") + assertEquals("sess-1", event.str("sessionId")) + val input = event.getValue("input").jsonObject + assertEquals("paste", input.str("kind")); assertEquals(text.replace("\n", "\r").replace("\u001b", ""), input.str("text")) + assertEquals(true, input.getValue("enter").jsonPrimitive.boolean) + assertEquals(ComposedInputResult.DELIVERED, stream.submitComposed(ComposedInput.Control("\u0003"))) + val control = h.awaitEvent("submitComposed").getValue("input").jsonObject + assertEquals("control", control.str("kind")); assertEquals("\u0003", control.str("text")) + assertFalse(control.getValue("enter").jsonPrimitive.boolean) + stream.detach() + } + } + + @Test fun `composed relay refusal uncertainty and legacy unsupported keep explicit outcomes without fallback`() = runBlocking { + for ((mode, expected) in listOf("refused" to ComposedInputResult.Status.REFUSED, + "uncertain" to ComposedInputResult.Status.UNCERTAIN, "unsupported" to ComposedInputResult.Status.REFUSED)) { + val h = start(extra = mapOf("FIXTURE_COMPOSED_INPUT" to mode)) + connect(h).connection.use { conn -> + val stream = conn.attach("term-abc-1", 80, 24, RecordingSink()) + val result = stream.submitComposed(ComposedInput.Paste("must keep draft", true)) + assertEquals(expected, result.status, mode) + assertTrue(result.message.orEmpty().isNotBlank(), mode) + if (mode != "unsupported") assertEquals("sess-1", h.awaitEvent("submitComposed").str("sessionId")) + stream.detach() + } + } + } + + // Actual backend/client/emulator producer through the encrypted relay and Kotlin API. + // Its native process leaf records bytes; this is not physical Windows ConPTY coverage. + @Test fun `session host composed Send producer interops with Android full paste delay and raw Ctrl`() = runBlocking { + alternativeComposedProducer("session-host") + } + + @Test fun `direct Windows composed Send producer interops with Android full paste delay and raw Ctrl`() = runBlocking { + alternativeComposedProducer("native-windows") + } + + private suspend fun alternativeComposedProducer(backend: String) { + for (mode in listOf("on", "off")) { + val h = start(extra = mapOf("FIXTURE_COMPOSED_BACKEND" to backend, "FIXTURE_COMPOSED_MODE" to mode)) + connect(h).connection.use { conn -> + val ready = h.awaitEvent("composed-backend-ready") + assertEquals(backend, ready.str("backend")) + assertEquals("byte-recorder", ready.str("nativeBoundary")) + assertTrue(ready.getValue("actualBackend").jsonPrimitive.boolean) + val stream = conn.attach("term-abc-1", 80, 24, RecordingSink()) + val text = "one complete Android draft\nΩ 😀\u001b" + assertEquals(ComposedInputResult.DELIVERED, stream.submitComposed(ComposedInput.Paste(text, true))) + val paste = h.awaitEvent("composed-native-write") + val enter = h.awaitEvent("composed-native-write") + val normalized = "one complete Android draft\rΩ 😀" + assertEquals(if (mode == "on") "\u001b[200~${normalized}\u001b[201~" else normalized, paste.str("data")) + assertEquals("\r", enter.str("data")) + assertTrue(enter.getValue("at").jsonPrimitive.double - paste.getValue("at").jsonPrimitive.double >= 150, + "Enter must be a later native write, separated by at least 150ms") + assertEquals(ComposedInputResult.DELIVERED, stream.submitComposed(ComposedInput.Control("\u0003"))) + assertEquals("\u0003", h.awaitEvent("composed-native-write").str("data")) + stream.detach() + assertEquals(ComposedInputResult.Status.REFUSED, stream.submitComposed(ComposedInput.Paste("stale", true)).status) + } + } + } + + @Test fun `native retained history producer reaches Android without wheel input`() = runBlocking { + alternativeHistoryProducer("native-windows") + } + @Test fun `session host retained history producer reaches Android without wheel input`() = runBlocking { + alternativeHistoryProducer("session-host") + } + private suspend fun alternativeHistoryProducer(backend: String) { + val h = start(extra = mapOf("FIXTURE_COMPOSED_BACKEND" to backend, "FIXTURE_HISTORY_SCROLL" to "off")) + connect(h).connection.use { conn -> + h.awaitEvent("composed-backend-ready") + val stream = conn.attach("term-abc-1", 80, 24, RecordingSink()) + var page = assertIs(stream.scrollView(true, 20)) + val observed = h.awaitEvent("history-native-result") + assertEquals("history", observed.str("status")) + assertTrue(observed.getValue("writes").jsonArray.isEmpty(), "Mouse-off scrolling must deliver zero raw input") + assertEquals(60, page.offset); assertTrue(page.totalRows > 200); assertEquals(24, page.rows.size) + val view = page.viewId + val original = page + h.command("history-more-output"); h.awaitEvent("history-control-done") + assertIs(stream.scrollView(false, 20)) + page = assertIs(stream.scrollView(true, 20)) + assertEquals(original, page, "Live output and normal-buffer trimming cannot rewrite an existing viewer snapshot") + repeat(8) { if (page.hasOlder) page = assertIs(stream.scrollView(true, 20)) } + assertEquals(view, page.viewId, "Every further page belongs to the same immutable viewer capture") + assertFalse(page.hasOlder) + assertTrue(page.rows.any { it.text.startsWith("H0000") }, "Pre-attach history must reach the oldest page") + val newer = assertIs(stream.scrollView(false, 1)) + assertEquals(page.offset - 3, newer.offset); assertEquals(view, newer.viewId) + val other = conn.attach("term-abc-1", 80, 24, RecordingSink()) + val otherPage = assertIs(other.scrollView(true, 1)) + assertTrue(view != otherPage.viewId, "A different attached viewer never adopts this viewer's viewId") + stream.clearScrollView() + val fresh = assertIs(stream.scrollView(true, 1)) + assertTrue(view != fresh.viewId, "Returning to live makes a later gesture capture a new view") + stream.detach(); other.detach() + assertIs(stream.scrollView(true, 1)) + } + } + @Test fun `actual backend mouse encoders interop as typed input without history fallback`() = runBlocking { + for (backend in listOf("native-windows", "session-host")) for ((mode, up, down) in listOf( + Triple("default", "\u001b[M`!!", "\u001b[Ma!!"), + Triple("sgr", "\u001b[<64;1;1M", "\u001b[<65;1;1M"), + Triple("pixels", "\u001b[<64;0;0M", "\u001b[<65;0;0M"))) { + val h = start(extra = mapOf("FIXTURE_COMPOSED_BACKEND" to backend, "FIXTURE_HISTORY_SCROLL" to mode)) + connect(h).connection.use { conn -> + h.awaitEvent("composed-backend-ready") + val stream = conn.attach("term-abc-1", 80, 24, RecordingSink()) + for ((direction, expected) in listOf(true to up, false to down)) { + assertEquals(TerminalScrollView.Result.Input, stream.scrollView(direction, 2)) + val observed = h.awaitEvent("history-native-result") + assertEquals("input", observed.str("status")) + assertEquals(listOf(expected, expected), observed.getValue("writes").jsonArray.map { it.jsonPrimitive.content }, "$backend/$mode") + } + stream.detach() + } + } + } + @Test fun `unadvertised relay scrolling refuses without guessing legacy tmux or typing wheels`() = runBlocking { + for (extra in listOf(mapOf("FIXTURE_HISTORY_SCROLL" to "unsupported"), + mapOf("FIXTURE_COMPOSED_BACKEND" to "native-windows", "FIXTURE_HISTORY_SCROLL" to "off", "FIXTURE_HISTORY_ADVERTISE_FALSE" to "1"))) { + val h = start(extra = extra) + connect(h).connection.use { conn -> + val stream = conn.attach("term-abc-1", 80, 24, RecordingSink()) + val result = stream.scrollView(true, 1) + h.command("history-state") + assertEquals(0, h.awaitEvent("history-state").getValue("calls").jsonPrimitive.int, + "An unadvertised method must be refused locally before any backend call") + h.awaitEvent("history-control-done") + assertIs(result) + stream.detach() + } + } + } + @Test fun `a held real history reply cannot restore a token after explicit live exit`() = runBlocking { + for (backend in listOf("native-windows", "session-host")) { + val h = start(extra = mapOf("FIXTURE_COMPOSED_BACKEND" to backend, "FIXTURE_HISTORY_SCROLL" to "off", "FIXTURE_HISTORY_HOLD" to "1")) + connect(h).connection.use { conn -> + val stream = conn.attach("term-abc-1", 80, 24, RecordingSink()) + val pending = async(start = CoroutineStart.UNDISPATCHED) { stream.scrollView(true, 1) } + h.awaitEvent("history-scroll-held") + stream.clearScrollView() + h.command("history-release"); h.awaitEvent("history-control-done") + assertIs(pending.await(), "A late reply may not become this viewer's current token") + assertIs(stream.scrollView(true, 1), "A later explicit gesture captures a new view") + stream.detach() + } + } + } + @Test fun `a held actual wheel result remains uncertain after live exit or viewer retirement without replay`() = runBlocking { + for (backend in listOf("native-windows", "session-host")) for (retire in listOf(false, true)) { + val h = start(extra = mapOf("FIXTURE_COMPOSED_BACKEND" to backend, "FIXTURE_HISTORY_SCROLL" to "sgr", "FIXTURE_HISTORY_HOLD" to "1")) + connect(h).connection.use { conn -> + val stream = conn.attach("term-abc-1", 80, 24, RecordingSink()) + val pending = async(start = CoroutineStart.UNDISPATCHED) { stream.scrollView(true, 2) } + h.awaitEvent("history-scroll-held") + h.awaitEvent("scrollAttached") + val observed = h.awaitEvent("history-native-result") + assertEquals("input", observed.str("status")) + assertEquals(listOf("\u001b[<64;1;1M", "\u001b[<64;1;1M"), observed.getValue("writes").jsonArray.map { it.jsonPrimitive.content }) + stream.clearScrollView() + if (retire) stream.retireComposed() + h.command("history-release"); h.awaitEvent("history-control-done") + assertIs(pending.await(), "Written wheels cannot become a confirmed refusal when the view closes") + h.command("history-mouse-off"); h.awaitEvent("history-control-done") + assertTrue(h.drainEvents().none { it.str("event") in setOf("history-native-result", "scrollAttached") }, "A sent scroll must never be retried") + stream.detach() + } + } + } + @Test fun `typed relay refusal and uncertainty preserve their outcomes without input fallback`() = runBlocking { + for (status in listOf("refused", "uncertain")) { + val h = start(extra = mapOf("FIXTURE_HISTORY_RESULT" to status)) + connect(h).connection.use { conn -> + val stream = conn.attach("term-abc-1", 80, 24, RecordingSink()) + val result = stream.scrollView(true, 1) + if (status == "refused") assertIs(result) + else assertIs(result) + stream.detach() + } + } + } + @Test fun `actual runtime mouse changes apply only after leaving immutable history`() = runBlocking { + for (backend in listOf("native-windows", "session-host")) { + val h = start(extra = mapOf("FIXTURE_COMPOSED_BACKEND" to backend, "FIXTURE_HISTORY_SCROLL" to "off")) + connect(h).connection.use { conn -> + val stream = conn.attach("term-abc-1", 80, 24, RecordingSink()) + val page = assertIs(stream.scrollView(true, 1)) + h.awaitEvent("history-native-result") + h.command("history-mouse-sgr"); h.awaitEvent("history-control-done") + assertEquals(page.viewId, assertIs(stream.scrollView(true, 1)).viewId, + "Application tracking changes never inject input into a retained history view") + stream.clearScrollView() + assertEquals(TerminalScrollView.Result.Input, stream.scrollView(true, 1)) + assertEquals(listOf("\u001b[<64;1;1M"), h.awaitEvent("history-native-result").getValue("writes").jsonArray.map { it.jsonPrimitive.content }) + h.command("history-mouse-off"); h.awaitEvent("history-control-done") + assertIs(stream.scrollView(true, 1)) + assertTrue(h.awaitEvent("history-native-result").getValue("writes").jsonArray.isEmpty()) + stream.detach() + } + } + } + + @Test fun `retired or detached relay viewer refuses composed Send before invoking host`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val stream = conn.attach("term-abc-1", 80, 24, RecordingSink()) + stream.retireComposed() + assertEquals(ComposedInputResult.Status.REFUSED, stream.submitComposed(ComposedInput.Paste("old", true)).status) + stream.write("raw unchanged") + assertEquals("raw unchanged", h.awaitEvent("write").str("data")) + stream.detach() + assertEquals(ComposedInputResult.Status.REFUSED, stream.submitComposed(ComposedInput.Paste("detached", true)).status) + } + } + + @Test + fun `board verbs carry null as the Ungrouped column`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + assertEquals(listOf("To Do"), conn.ensureBoard("p1")!!.map { it.title }) + assertTrue(conn.setCardColumn("p1", "term-abc-1", null)) + val moved = h.awaitEvent("setCardColumn") + assertEquals(JsonNull, moved["columnId"]) + assertTrue(conn.setCardColumn("p1", "term-abc-1", "c1")) + assertEquals("c1", h.awaitEvent("setCardColumn").str("columnId")) + val res = conn.editCardLabels("p1", "term-abc-1", CardLabelEdit(add = listOf("l1"), create = listOf("urgent" to "red")))!! + assertTrue(res.edited) + assertEquals(listOf("l1"), res.cardLabelIds) + val edit = h.awaitEvent("editCardLabels")["edit"].toString() + assertTrue(edit.contains("urgent"), edit) + assertTrue(conn.registerNode("p1", NewNode("term-kx1-abc", "Claude Code", "claude", null))) + assertEquals("p1", h.awaitEvent("registerNode").str("projectId")) + } + } + + @Test + fun `quick approve answers the held hook through the relay verb, never with keys`() = runBlocking { + val h = start() + connect(h).connection.use { conn -> + val event = conn.listProjects().status!!.inbox!!.events.single() + assertEquals(QuickActions.Result.SENT, QuickActions.answerApproval(conn, event, allow = true)) + val answer = h.awaitEvent("answer") + assertEquals("allow", answer.str("decision")) + assertEquals("term-abc-1-1700000000000-42", answer.str("pendingId")) + conn.ackRead("term-abc-1", event.id) + assertEquals("term-abc-1", h.awaitEvent("ack").str("nodeId")) + assertNotNull(event) + } + } + + @Test + fun `new hook capabilities and complete answers interoperate with the real host producer and verbs`() = runBlocking { + val h = start(extra = mapOf("FIXTURE_STRUCTURED_HOOKS" to "1")) + connect(h).connection.use { conn -> + val feed = conn.listProjects().status!!.inbox!!.events + val permission = feed.single { it.kind == InboxKind.APPROVAL } + assertEquals("Bash(npm test) — localSettings", permission.permissionSuggestions.single().label) + assertEquals(QuickActions.Result.SENT, QuickActions.rememberApproval(conn, permission, 0)) + val remembered = h.awaitEvent("answer") + assertEquals("allow-always", remembered.str("decision")) + assertEquals("0", remembered.str("suggestionIndex")) + val question = feed.single { it.kind == InboxKind.QUESTION } + assertTrue(question.options.isEmpty(), "an old consumer must not receive numbered actions") + assertEquals(2, question.questions.size) + assertEquals(QuickActions.Result.SENT, QuickActions.answerQuestions(conn, question, listOf(listOf(0, 1), listOf(1)))) + val answer = h.awaitEvent("question-answer") + assertEquals("term-question-1-1700000000000-43", answer.str("pendingId")) + assertEquals("[[0,1],[1]]", answer["selections"].toString()) + assertEquals(dev.nodeterm.protocol.host.ApprovalOutcome.GONE, + conn.answerQuestions(question.copy(questionPendingId = "term-question-1-1700000000000-44-expired"), listOf(listOf(0), listOf(1)))) + } + } + + @Test + fun `an older host without structured questions never falls back to typing before the held picker paints`() = runBlocking { + val h = start(extra = mapOf("FIXTURE_STRUCTURED_HOOKS" to "1", "FIXTURE_NO_STRUCTURED_QUESTIONS" to "1")) + connect(h).connection.use { conn -> + val event = conn.listProjects().status!!.inbox!!.events.single { it.kind == InboxKind.QUESTION } + assertEquals(QuickActions.Result.OPEN_SESSION, QuickActions.answerQuestions(conn, event, listOf(listOf(0), listOf(1)))) + } + } + + @Test + fun `an answer that arrives after the hold ended opens the session instead of claiming success`() = runBlocking { + // A06/A35 against the desktop's real verb: `{answered:false, reason:"gone"}`, and the node is + // still blocked, so the prompt is on screen now. + val h = start() + connect(h).connection.use { conn -> + val live = conn.listProjects().status!!.inbox!!.events.single() + val late = live.copy(id = "e-late", pendingId = "term-abc-1-1700000000000-43-expired") + assertEquals(dev.nodeterm.protocol.host.ApprovalOutcome.GONE, conn.answerApproval(late, allow = true)) + assertEquals(QuickActions.Result.EXPIRED, QuickActions.answerApproval(conn, late, allow = false)) + } + } + + @Test + fun `quick answers are typed through the node's session, not a throwaway client`() = runBlocking { + // A12: node.sendKeys through the desktop's real verb handler. + val h = start() + connect(h).connection.use { conn -> + conn.sendKeys("term-abc-1", "\u001b") + val ev = h.awaitEvent("sendKeys") + assertEquals("term-abc-1", ev.str("nodeId")) + assertEquals("\u001b", ev.str("keys")) + // Not delivered is not "sent": the caller opens the session instead. + assertFailsWith { conn.sendKeys("term-gone-1", "1") } + } + } + + @Test + fun `a quick answer for a node of the desktop's SSH project is typed on that host`() = runBlocking { + // Follow-up to A12, through the desktop's real verb handler and its A09 resolver: the keys + // go over the project's master instead of answering sent:false (which opened the session). + val h = start() + connect(h).connection.use { conn -> + conn.sendKeys("ssh-abc-1", "1") + val ev = h.awaitEvent("sendKeysOver") + assertEquals("ssh-abc-1", ev.str("nodeId")) + assertEquals("1", ev.str("keys")) + assertEquals("/cm/box.sock", ev.str("controlPath")) + // Never the local writer: the first local `sendKeys` the desktop sees is this later one. + conn.sendKeys("term-abc-1", "2") + assertEquals("term-abc-1", h.awaitEvent("sendKeys").str("nodeId")) + // A session the host does not have, and a project whose master is down, are not "sent": + // the caller opens the session instead. + assertFailsWith { conn.sendKeys("ssh-gone-1", "1") } + assertFailsWith { conn.sendKeys("ssh-offline-1", "1") } + } + } + + @Test + fun `an older desktop without the verb still gets the keys, after the pane painted`() = runBlocking { + val h = start(extra = mapOf("FIXTURE_NO_SENDKEYS" to "1")) + connect(h).connection.use { conn -> + conn.sendKeys("term-abc-1", "2") + assertEquals("2", h.awaitEvent("write").str("data")) + } + } + + @Test + fun `a session the phone starts is created in its project by the desktop`() = runBlocking { + // A33 + A72, through the desktop's real pty.attach handler and its real resolver: the host + // applies the project folder and account, the AGENT (what gives the session its + // agent-specific hook env: approvals, canvas control) and the pane OWNER it resolved from + // its own index. The fixture's session "does not exist", so this attach creates it. + val h = start() + connect(h).connection.use { conn -> + val s = conn.attach("term-new-9", 90, 30, RecordingSink(), dev.nodeterm.protocol.host.NewSessionHint("p1", "acct-1", "claude")) + val ev = h.awaitEvent("attach") + assertEquals("term-new-9", ev.str("persistKey")) + assertTrue(s.fresh) + assertEquals("/repo", ev.str("cwd")) + assertEquals("acct-1", ev.str("accountId")) + assertEquals("claude", ev.str("agentId")) + assertEquals("p1", ev.str("ownerProjectId")) + s.detach() + } + } + + @Test + fun `managed phone creation consumes the real trusted project settings before launch input (A72)`() = runBlocking { + val h = startLaunch(true) + connect(h).connection.use { conn -> + val sink = RecordingSink() + val stream = conn.attach("term-settings-new", 80, 24, sink, + dev.nodeterm.protocol.host.NewSessionHint("launch-project", null, "claude")) + assertTrue(stream.fresh) + sink.awaitText("launch-env:trusted-project shell:/bin/sh") + stream.write("A72_NEW_LAUNCH\r") + val written = h.awaitEvent("launch-write") + assertEquals("A72_NEW_LAUNCH\r", written.str("text")) + assertEquals(h.str("projectCwd"), written.str("cwd")) + assertEquals("trusted-project", written.str("projectEnv")) + assertEquals("/bin/sh", written.str("shell")) + stream.detach() + } + } + + @Test + fun `cold saved node attach restores host project settings and cwd without wire hints (A72)`() = runBlocking { + val h = startLaunch(true) + connect(h).connection.use { conn -> + val sink = RecordingSink() + val stream = conn.attach("term-settings-saved", 80, 24, sink) + assertTrue(stream.fresh) + sink.awaitText("launch-env:trusted-project shell:/bin/sh") + stream.write("A72_SAVED_LAUNCH\r") + val written = h.awaitEvent("launch-write") + assertEquals(h.str("savedCwd"), written.str("cwd")) + assertEquals("trusted-project", written.str("projectEnv")) + assertEquals("/bin/sh", written.str("shell")) + stream.detach() + } + } + + @Test + fun `untrusted shared project env and shell stay out of a managed phone cold spawn (A72)`() = runBlocking { + val h = startLaunch(false) + connect(h).connection.use { conn -> + val sink = RecordingSink() + val stream = conn.attach("term-settings-saved", 80, 24, sink) + sink.awaitText("launch-env:absent shell:") + stream.write("A72_UNTRUSTED_LAUNCH\r") + val written = h.awaitEvent("launch-write") + assertEquals(null, written["projectEnv"]?.takeUnless { it.toString() == "null" }) + assertFalse(written.str("shell") == "/bin/sh") + assertEquals(setOf("agents", "shell"), setOf(h.awaitEvent("launch-trust").str("family"), + h.awaitEvent("launch-trust").str("family"))) + stream.detach() + } + } + + @Test + fun `a hint on an attach that joins a live session changes nothing, the owner included`() = runBlocking { + // A72: the phone must not be able to claim a running pane for its project by naming one. + // The fixture's `term-a` exists, so this attach is a join and the host applies nothing. + val h = start() + connect(h).connection.use { conn -> + val s = conn.attach("term-a", 90, 30, RecordingSink(), dev.nodeterm.protocol.host.NewSessionHint("p1", null, "claude")) + val ev = h.awaitEvent("attach") + assertEquals("term-a", ev.str("persistKey")) + assertEquals(false, s.fresh) + assertEquals(null, ev["cwd"]) + assertEquals(null, ev["agentId"]) + assertEquals(null, ev["ownerProjectId"]) + s.detach() + } + } + + /** + * Audit A29: the phone's Source Control drives the desktop's REAL git bridge — `handleGit` behind + * its jail, handing each verb to the real `GitService` (src/core/git-service.ts) — over a git + * repository in the project's folder (seedGitRepo in host-fixture.ts). The folder is the one + * `projects.list` names, as the app takes it. + */ + @Test + fun `source control works the project's repository through the desktop's git bridge`() = runBlocking { + val h = start() + val connected = connect(h) + connected.connection.use { conn -> + val cwd = connected.first.projects.single().cwd!! + val git = SourceControl(conn, cwd) + + val st = git.status() + assertTrue(st.hasRepo) + assertEquals("main", st.branch) + assertTrue(st.hasRemote && st.hasUpstream, "the seed pushed main with -u") + assertEquals(0, st.ahead) + assertEquals(listOf(GitFileChange("staged.txt", "A", 1, 0)), st.staged) + assertEquals(listOf(GitFileChange("README.md", "M", 1, 1)), st.unstaged) + assertEquals(listOf("new.txt"), st.untracked.map { it.path }) + + // Each side of the diff, and the whole file for an untracked one. + val readme = git.diff(st.unstaged.single(), staged = false) + assertTrue(GitDiff.Line(GitDiff.Kind.DEL, "-hello") in readme.lines, "${readme.lines}") + assertTrue(GitDiff.Line(GitDiff.Kind.ADD, "+hello again") in readme.lines, "${readme.lines}") + assertTrue(readme.lines.any { it.kind == GitDiff.Kind.HUNK }) + assertTrue(GitDiff.Line(GitDiff.Kind.ADD, "+staged") in git.diff(st.staged.single(), staged = true).lines) + assertTrue(GitDiff.Line(GitDiff.Kind.ADD, "+new file") in git.diff(st.untracked.single(), staged = false).lines) + + // Stage the untracked file, unstage the staged one: both move. + assertEquals(GitResult(true, ""), git.stage(listOf("new.txt"))) + assertEquals(GitResult(true, ""), git.unstage(listOf("staged.txt"))) + val moved = git.status() + assertEquals(listOf("new.txt"), moved.staged.map { it.path }) + assertEquals(listOf("staged.txt"), moved.untracked.map { it.path }) + + // A git command that fails on the computer is an ANSWER carrying git's own words, not an + // RPC error: the screen shows it as it is. + val badPath = git.stage(listOf("no-such-file")) + assertFalse(badPath.ok) + assertTrue(badPath.message.contains("no-such-file"), badPath.message) + // The desktop refuses an empty message itself; the phone's button never sends one. + assertEquals(GitResult(false, "Commit message is empty."), git.commit(" ")) + + val commit = git.commit("Add new.txt from the phone") + assertTrue(commit.ok, commit.message) + assertTrue(commit.message.contains("Add new.txt from the phone"), commit.message) + val after = git.status() + assertEquals(1, after.ahead) + assertTrue(after.staged.isEmpty()) + + val history = git.history() + assertEquals(listOf("Add new.txt from the phone", "initial"), history.commits.map { it.subject }) + val head = history.commits.first() + assertEquals("Interop", head.author) + assertEquals(head.id.take(head.shortId.length), head.shortId) + assertTrue((head.timestampMs ?: 0) > 1_600_000_000_000, "author time in ms: ${head.timestampMs}") + assertTrue("main" in head.refs, "${head.refs}") + assertTrue(history.hasOutgoingChanges) + + assertEquals(GitResult(true, "Pushed."), git.push()) + assertEquals(0, git.status().ahead) + val pulled = git.pull() + assertTrue(pulled.ok, pulled.message) + assertFalse(git.history().hasOutgoingChanges) + } + } + + /** + * Review of A29: a merge that conflicted, read through the desktop's real `GitService`. Its status + * sends an unmerged path's `U` exactly like an untracked file's (porcelain `??`), in both lists. + * The phone lists such a path as a conflict, opens it as the combined diff with its markers (never + * `git diff --no-index /dev/null `, which showed the conflicted file as a NEW one), and does + * not offer the commit git refuses. The merge is made here with git itself, as it would be on the + * computer (a Pull from the phone that merges ends in the same state). + */ + @Test + fun `a merge that conflicted reaches the phone as conflicts, not as untracked files`() = runBlocking { + val h = start() + val connected = connect(h) + connected.connection.use { conn -> + val cwd = connected.first.projects.single().cwd!! + val noConfig = Files.createTempFile("gitconfig", "").toFile().also { it.deleteOnExit() } + fun git(vararg args: String): Int { + val pb = ProcessBuilder(listOf("git") + args).directory(File(cwd)).redirectErrorStream(true) + // The repository's own config (seedGitRepo: identity, no hooks, no signing) and nothing of + // the machine's, so a user's merge settings cannot change the conflict. + pb.environment()["GIT_CONFIG_GLOBAL"] = noConfig.path + pb.environment()["GIT_CONFIG_NOSYSTEM"] = "1" + val proc = pb.start() + proc.inputStream.readBytes() + return proc.waitFor() + } + fun write(name: String, text: String) = File(cwd, name).writeText(text) + + // The base: the seed's README, staged.txt and new.txt, committed. + assertEquals(0, git("add", "-A")) + assertEquals(0, git("commit", "-q", "-m", "base")) + // Theirs: README and staged.txt changed, new.txt deleted, both.txt added. + assertEquals(0, git("checkout", "-q", "-b", "theirs")) + write("README.md", "theirs\n") + write("staged.txt", "theirs\n") + write("both.txt", "theirs\n") + assertEquals(0, git("rm", "-q", "new.txt")) + assertEquals(0, git("add", "-A")) + assertEquals(0, git("commit", "-q", "-m", "theirs")) + // Ours: README and new.txt changed, staged.txt deleted, both.txt added differently. + assertEquals(0, git("checkout", "-q", "main")) + write("README.md", "ours\n") + write("new.txt", "ours\n") + write("both.txt", "ours\n") + assertEquals(0, git("rm", "-q", "staged.txt")) + assertEquals(0, git("add", "-A")) + assertEquals(0, git("commit", "-q", "-m", "ours")) + assertEquals(1, git("merge", "theirs"), "the merge conflicts") + write("fresh.txt", "fresh\n") + + val git = SourceControl(conn, cwd) + val st = git.status() + assertEquals( + setOf( + GitConflict("README.md", "U", "U"), GitConflict("both.txt", "A", "A"), + GitConflict("new.txt", "U", "D"), GitConflict("staged.txt", "D", "U") + ), + st.conflicts.toSet() + ) + assertEquals(listOf("fresh.txt"), st.untracked.map { it.path }, "only the file git does not track is untracked") + assertTrue(st.staged.isEmpty(), "${st.staged}") + assertTrue(st.unstaged.isEmpty(), "${st.unstaged}") + assertEquals("Resolve the conflicts on the computer first.", SourceControl.commitBlocker(st, "Merge theirs")) + + val readme = git.diff(st.conflicts.single { it.path == "README.md" }) + assertEquals(GitDiff.Line(GitDiff.Kind.META, "diff --cc README.md"), readme.lines.first(), "${readme.lines}") + assertTrue(readme.lines.none { "/dev/null" in it.text }, "not the untracked form: ${readme.lines}") + for (marker in listOf("<<<<<<<", "=======", ">>>>>>>")) { + assertTrue(readme.lines.any { it.kind == GitDiff.Kind.ADD && marker in it.text }, "$marker: ${readme.lines}") + } + assertTrue(GitDiff.Line(GitDiff.Kind.ADD, " +ours") in readme.lines, "${readme.lines}") + + // git itself refuses the commit, which is what the button says first. + val commit = git.commit("Merge theirs") + assertFalse(commit.ok) + assertTrue(commit.message.contains("unmerged"), commit.message) + } + } + + @Test + fun `the git bridge's refusals reach the phone as the host's own sentences`() = runBlocking { + val h = start() + val connected = connect(h) + connected.connection.use { conn -> + val cwd = connected.first.projects.single().cwd!! + // The jail is lexical and resolves `..`: the folder above the project is outside it. + for (outside in listOf("/etc", "$cwd/..", "")) { + val e = assertFailsWith { SourceControl(conn, outside).status() } + assertEquals("cwd is outside the shared project roots.", e.message, outside) + } + } + + val bare = start(extra = mapOf("FIXTURE_NO_GIT" to "1")) + val c2 = connect(bare) + c2.connection.use { conn -> + val e = assertFailsWith { SourceControl(conn, c2.first.projects.single().cwd!!).status() } + assertEquals("git is not served on this host.", e.message) + } + } + + // ---- A74-refresh: the computer's LAN leg, reported beside the listing ----------------------------- + + /** sshd's `ssh_host__key.pub` files for [keys], under a new dir (the layout of /etc/ssh). */ + private fun hostKeyDir(vararg keys: Pair): File { + val dir = Files.createTempDirectory("nt-relay-etc-ssh").toFile().also { userDataDirs += it } + for ((name, key) in keys) { + val blob = Buffer.PlainBuffer().putPublicKey(key).compactData + File(dir, "ssh_host_${name}_key.pub").writeText("${KeyType.fromKey(key)} ${java.util.Base64.getEncoder().encodeToString(blob)} root@box\n") + } + return dir + } + + /** A real SSH server that lets [identity] in as `dev`, and its host key. */ + private fun sshServer(identity: SshIdentity): Pair { + val root = Files.createTempDirectory("nt-relay-sshd").toFile().also { userDataDirs += it } + val server = SshServer.setUpDefaultServer() + server.host = "127.0.0.1" + server.port = 0 + server.keyPairProvider = SimpleGeneratorHostKeyProvider(File(root, "hostkey.ser").toPath()) + val accepted = identity.keyPair.public.encoded + server.publickeyAuthenticator = PublickeyAuthenticator { user, key, _ -> user == "dev" && key.encoded.contentEquals(accepted) } + server.start() + sshServers += server + return server to server.keyPairProvider.loadKeys(null).first().public + } + + /** The pin as the app keeps it (ConnectionManager's `pinFor`): read from, and written to, the record. */ + private class RecordPin(var record: PairedHost) : HostKeyPin { + override fun pinned() = record.sshHostKeyFingerprint + override fun pin(fingerprint: String) { + record = record.copy(sshHostKeyFingerprint = fingerprint) + } + override fun anchors() = record.sshHostKeyAnchors + } + + @Test + fun `the relay listing names the computer's LAN address and SSH keys, and the phone trusts its new key from it (A74-refresh)`() = runBlocking { + // The computer's sshd keys were regenerated (a reinstall) and its lease moved since the phone + // paired: the record still says the old address and pins a key the computer no longer has. + val identity = SshIdentity.generate() + val (sshd, serverKey) = sshServer(identity) + val old = java.security.KeyPairGenerator.getInstance("EC").apply { initialize(256) }.generateKeyPair().public + val oldFp = SshHostConnection.fingerprint(old) + val serverFp = SshHostConnection.fingerprint(serverKey) + val h = start(extra = mapOf("FIXTURE_SSH_HOST_KEY_DIR" to hostKeyDir("ecdsa" to serverKey).path, "FIXTURE_LAN_ADDRESS" to "192.168.77.20")) + val connected = connect(h) + connected.connection.use { conn -> + // The desktop's own reporter (the QR's address pick, the sealed answer's key reader), beside the blob. + assertEquals(LanReport("192.168.77.20", listOf(serverFp)), connected.first.lan) + // Every listing carries it, so a lease that moves while the phone is connected is picked up. + assertEquals(connected.first.lan, conn.listProjects().lan) + // The blob is what it was: the projects still parse. + assertEquals("Demo", connected.first.projects.single().name) + } + + val record = PairedHost( + id = "dev-1", name = "Box", host = "192.168.1.5", port = 22, user = "dev", sshAvailable = true, + hostKeyB64 = h.str("hostPublicKeyB64"), relay = null, sshHostKeyFingerprint = oldFp, pairedAt = 1, + sshHostKeyAnchors = listOf(oldFp) + ) + // Before the refresh the server's new key is a changed key: refused over SSH, pin untouched. + val before = RecordPin(record) + val changed = assertFailsWith { SshHostConnection.connect("127.0.0.1", sshd.port, "dev", identity, before).close() } + assertEquals(oldFp, before.record.sshHostKeyFingerprint) + // The app goes on to the relay with the key it was refused, as SshFallback hands it on. + val refused = assertIs(SshFallback.afterFailure(changed, relayAllowed = true, relayConfigured = true)).refusedHostKey + assertEquals(serverFp, refused) + + // A listing over SSH carries no report, and one that did would still not count. + assertNull(LanRefresh.afterListing(record, TransportKind.SSH, connected.first, refused)) + // A report alone moves the address and the anchors, never a pin the phone has not seen fail + // (review of A74-refresh: the report is what the desktop could read, not what sshd serves). + val unrefused = assertNotNull(LanRefresh.afterListing(record, TransportKind.RELAY, connected.first, refusedHostKey = null)) + assertEquals(oldFp, unrefused.host.sshHostKeyFingerprint) + assertNull(unrefused.confirmedKey) + // The key the SSH leg was refused, confirmed by the computer: the pin gives way to it. + val refreshed = assertNotNull(LanRefresh.afterListing(record, TransportKind.RELAY, connected.first, refused)) + assertTrue(refreshed.addressChanged) + assertEquals(serverFp, refreshed.confirmedKey) + assertEquals("192.168.77.20", refreshed.host.host) + assertNull(refreshed.host.sshHostKeyFingerprint) + assertEquals(listOf(serverFp), refreshed.host.sshHostKeyAnchors) + + // After it, the next connect accepts the key the computer confirmed, and pins exactly that one. + val after = RecordPin(refreshed.host) + SshHostConnection.connect("127.0.0.1", sshd.port, "dev", identity, after).close() + assertEquals(serverFp, after.record.sshHostKeyFingerprint) + // The record now says what the computer says: the next listing changes nothing. + assertNull(LanRefresh.afterListing(after.record, TransportKind.RELAY, connected.first, refused), "nothing left to refresh") + } + + /** + * Review of A74-refresh: the desktop reports every `ssh_host_*_key.pub` on disk, whether sshd serves + * it or not. When sshd stops serving the pinned key (its `HostKey` line removed, `HostKeyAlgorithms` + * narrowed) and the `.pub` stays, the pin is still among the reported keys while the server presents + * another one of them. Dropping a pin only when it was missing from the report left SSH refused + * there for good; the key the SSH leg was refused, confirmed by the computer, is what lets it in. + */ + @Test + fun `a pin the computer still reports but sshd no longer serves gives way to the key it serves (A74-refresh)`() = runBlocking { + val identity = SshIdentity.generate() + val (sshd, servedKey) = sshServer(identity) + val unserved = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair().public + val unservedFp = SshHostConnection.fingerprint(unserved) + val servedFp = SshHostConnection.fingerprint(servedKey) + val h = start(extra = mapOf("FIXTURE_SSH_HOST_KEY_DIR" to hostKeyDir("ed25519" to unserved, "ecdsa" to servedKey).path)) + val connected = connect(h) + val reported = assertNotNull(connected.first.lan).sshHostKeyFingerprints + connected.connection.use { assertEquals(setOf(unservedFp, servedFp), reported.toSet()) } + // Paired while sshd served the ed25519 key: the pairing named the same two keys, and the first + // connect pinned that one. + val record = PairedHost( + id = "dev-1", name = "Box", host = "127.0.0.1", port = sshd.port, user = "dev", sshAvailable = true, + hostKeyB64 = h.str("hostPublicKeyB64"), relay = null, sshHostKeyFingerprint = unservedFp, pairedAt = 1, + sshHostKeyAnchors = reported + ) + val changed = assertFailsWith { SshHostConnection.connect("127.0.0.1", sshd.port, "dev", identity, RecordPin(record)).close() } + val refused = assertIs(SshFallback.afterFailure(changed, relayAllowed = true, relayConfigured = true)).refusedHostKey + assertEquals(servedFp, refused) + + // The pin is among the reported keys, so a report alone keeps it, and SSH stays refused. + assertNull(LanRefresh.afterListing(record, TransportKind.RELAY, connected.first, refusedHostKey = null)) + val refreshed = assertNotNull(LanRefresh.afterListing(record, TransportKind.RELAY, connected.first, refused)) + assertNull(refreshed.host.sshHostKeyFingerprint) + assertEquals(servedFp, refreshed.confirmedKey) + val after = RecordPin(refreshed.host) + SshHostConnection.connect("127.0.0.1", sshd.port, "dev", identity, after).close() + assertEquals(servedFp, after.record.sshHostKeyFingerprint) + } + + @Test + fun `a computer whose keys did not change keeps its pin, and one that sends nothing changes nothing (A74-refresh)`() = runBlocking { + val identity = SshIdentity.generate() + val (_, serverKey) = sshServer(identity) + val serverFp = SshHostConnection.fingerprint(serverKey) + val record = PairedHost( + id = "dev-1", name = "Box", host = "192.168.1.5", port = 22, user = "dev", sshAvailable = true, + hostKeyB64 = null, relay = null, sshHostKeyFingerprint = serverFp, pairedAt = 1 + ) + + // Same keys, new lease: only the address moves. + val moved = connect(start(extra = mapOf("FIXTURE_SSH_HOST_KEY_DIR" to hostKeyDir("ecdsa" to serverKey).path, "FIXTURE_LAN_ADDRESS" to "192.168.1.6"))) + moved.connection.use { + val r = assertNotNull(LanRefresh.afterListing(record, TransportKind.RELAY, moved.first, refusedHostKey = null)) + assertEquals(record.copy(host = "192.168.1.6", sshHostKeyAnchors = listOf(serverFp)), r.host) + assertNull(r.confirmedKey) + } + + // A desktop with no readable keys and no LAN address (offline Wi-Fi) reports nothing at all. + val silent = connect(start()) + silent.connection.use { + assertNull(silent.first.lan) + assertNull(LanRefresh.afterListing(record, TransportKind.RELAY, silent.first, refusedHostKey = null)) + } + + // A desktop that predates the field: the same, and the listing is what it always was. + val older = connect(start(extra = mapOf("FIXTURE_NO_LAN" to "1", "FIXTURE_LAN_ADDRESS" to "10.0.0.9"))) + older.connection.use { + assertNull(older.first.lan) + assertNull(LanRefresh.afterListing(record, TransportKind.RELAY, older.first, refusedHostKey = null)) + assertEquals("Demo", older.first.projects.single().name) + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayPairingProofTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayPairingProofTest.kt new file mode 100644 index 0000000000..10ff385d72 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelayPairingProofTest.kt @@ -0,0 +1,244 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.model.PairedHost +import dev.nodeterm.protocol.pairing.RelayBlock +import dev.nodeterm.protocol.pairing.RelayPairingProof +import dev.nodeterm.protocol.pairing.RelayPairingProof.Outcome +import dev.nodeterm.protocol.pairing.SshIdentity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import net.i2p.crypto.eddsa.EdDSAEngine +import java.security.MessageDigest +import kotlin.test.* + +/** Real Ed25519 signing with an inert RPC boundary; actual host producer interop is separate. */ +class RelayPairingProofTest { + private val pairingId = "f65a1d12-2577-4190-b528-9cdf9e7bcc35" + private val challengeId = "79ee12c7-f537-4536-a150-11d093d87f96" + private val hostKey = B64.encode(ByteArray(32) { 3 }) + private val peerKey = B64.encode(ByteArray(32) { 7 }) + private val seed = ByteArray(32) { it.toByte() } + private val identity = SshIdentity.fromSeed(seed) + private val host = PairedHost(pairingId, "Synthetic desktop", "192.0.2.1", 22, "fixture", true, + hostKey, RelayBlock("fixture-host", hostKey, "wss://relay.nodeterm.dev"), null, 1) + + private fun context(record: PairedHost = host, pinned: String = hostKey, peer: String = peerKey, + read: () -> ByteArray? = { seed.copyOf() }, current: () -> Unit = {}) = + RelayPairingProof.Context(record, pinned, peer, read, current) + + private fun challenge(vararg overrides: Pair): JsonObject { + val fields = buildJsonObject { + put("version", 1); put("challengeId", challengeId); put("pairingId", pairingId) + put("hostPublicKeyB64", hostKey); put("peerPublicKeyB64", peerKey) + put("sshPublicKeyB64", B64.encode(identity.publicKeyBytes)); put("nonceB64", B64.encode(ByteArray(32) { 9 })) + put("expiresAtMs", 61_000) + }.toMutableMap().apply { putAll(overrides) } + return buildJsonObject { put("status", "challenge"); put("challenge", JsonObject(fields)) } + } + + private fun status(value: String) = buildJsonObject { put("status", value) } + + @Test fun `proof signs the exact domain-separated challenge with the retained Ed25519 identity`() = runBlocking { + val calls = mutableListOf>() + val result = RelayPairingProof.afterApprovedListing(context(), rpc = { method, params -> + calls += method to params + if (method == RelayPairingProof.CHALLENGE_METHOD) challenge() else { + assertEquals(setOf("challengeId", "signatureB64"), params.keys) + assertEquals(JsonPrimitive(challengeId), params["challengeId"]) + val signature = B64.decode((params["signatureB64"] as JsonPrimitive).content)!! + assertEquals(64, signature.size) + val expected = listOf("nodeterm-relay-pairing-v1", "1", challengeId, pairingId, hostKey, + peerKey, B64.encode(identity.publicKeyBytes), B64.encode(ByteArray(32) { 9 }), "61000", "").joinToString("\n").toByteArray() + val verifier = EdDSAEngine(MessageDigest.getInstance("SHA-512")) + verifier.initVerify(identity.keyPair.public); verifier.update(expected) + assertTrue(verifier.verify(signature), "Only the exact retained SSH public key may verify this proof") + status("associated") + } + }, now = { 1_000 }) + assertEquals(Outcome.ASSOCIATED, result) + assertEquals(listOf(RelayPairingProof.CHALLENGE_METHOD, RelayPairingProof.PROOF_METHOD), calls.map { it.first }) + assertEquals(buildJsonObject { put("pairingId", pairingId) }, calls.first().second) + } + + @Test fun `challenge never overrides captured pairing host relay peer or retained SSH public key`() = runBlocking { + val wrong = B64.encode(ByteArray(32) { 4 }) + val changes = listOf("pairingId" to JsonPrimitive(challengeId), "hostPublicKeyB64" to JsonPrimitive(wrong), + "peerPublicKeyB64" to JsonPrimitive(wrong), "sshPublicKeyB64" to JsonPrimitive(wrong)) + for (change in changes) { + var proofs = 0 + val result = RelayPairingProof.afterApprovedListing(context(), rpc = { method, _ -> + if (method == RelayPairingProof.PROOF_METHOD) proofs++ + challenge(change) + }, now = { 1_000 }) + assertEquals(Outcome.UNPROVABLE, result, change.first); assertEquals(0, proofs, change.first) + } + } + + @Test fun `strict wire types UUIDs canonical padding nonce and safe expiry refuse signing`() = runBlocking { + val invalid = listOf( + "version" to JsonPrimitive("1"), "version" to JsonPrimitive(2), + "challengeId" to JsonPrimitive(challengeId.uppercase()), "challengeId" to JsonPrimitive("arbitrary\nidentifier"), + "pairingId" to JsonPrimitive("not-a-uuid"), + "hostPublicKeyB64" to JsonPrimitive(hostKey.trimEnd('=')), + "peerPublicKeyB64" to JsonPrimitive(" $peerKey"), + "sshPublicKeyB64" to JsonPrimitive(B64.encode(ByteArray(31))), + "nonceB64" to JsonPrimitive(B64.encode(ByteArray(33))), + "nonceB64" to JsonPrimitive("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB="), + "expiresAtMs" to JsonPrimitive("61000"), "expiresAtMs" to JsonPrimitive(1_000), + "expiresAtMs" to JsonPrimitive(0), "expiresAtMs" to JsonPrimitive(9_007_199_254_740_992L), + ) + for (field in invalid) { + var proofs = 0 + val result = RelayPairingProof.afterApprovedListing(context(), rpc = { method, _ -> + if (method == RelayPairingProof.PROOF_METHOD) proofs++ + challenge(field) + }, now = { 1_000 }) + assertEquals(Outcome.UNPROVABLE, result, field.toString()); assertEquals(0, proofs, field.toString()) + } + assertNull(RelayPairingProof.parseChallenge(challenge(), context(), identity.publicKeyBytes, -1)) + } + + @Test fun `host expiry does not require the phone clock to share the hosts exact TTL origin`() = runBlocking { + var proofs = 0 + assertEquals(Outcome.ASSOCIATED, RelayPairingProof.afterApprovedListing(context(), rpc = { method, _ -> + if (method == RelayPairingProof.CHALLENGE_METHOD) challenge() else { proofs++; status("associated") } + }, now = { 900 })) + assertEquals(1, proofs, "The host, which consumes the proof once, enforces its monotonic sixty-second deadline") + } + + @Test fun `manual unpinned conflicting and invalid pairing contexts never read or create identity`() = runBlocking { + val wrong = B64.encode(ByteArray(32) { 6 }) + val cases = listOf(context(host.copy(manual = true)), context(host.copy(hostKeyB64 = null)), + context(host.copy(hostKeyB64 = wrong)), context(host.copy(relay = host.relay!!.copy(hostPublicKeyB64 = wrong))), + context(host.copy(id = "legacy-invalid-id")), context(pinned = "not-a-key"), context(peer = "not-a-key")) + for (case in cases) { + var reads = 0; var calls = 0 + val captured = context(case.host, case.pinnedHostKeyB64, case.peerPublicKeyB64, read = { reads++; seed }) + assertEquals(Outcome.UNPROVABLE, RelayPairingProof.afterApprovedListing(captured, rpc = { _, _ -> calls++; challenge() }, now = { 1_000 })) + assertEquals(0, reads); assertEquals(0, calls) + } + } + + @Test fun `missing wrong-sized or unavailable retained seed stays unprovable without any RPC`() = runBlocking { + for (read in listOf<() -> ByteArray?>({ null }, { ByteArray(31) }, { throw IllegalStateException("Synthetic unavailable secret") })) { + var calls = 0 + assertEquals(Outcome.UNPROVABLE, RelayPairingProof.afterApprovedListing(context(read = read), rpc = { _, _ -> calls++; challenge() }, now = { 1_000 })) + assertEquals(0, calls) + } + } + + @Test fun `terminal host statuses never send a proof or repeat the challenge`() = runBlocking { + for ((name, expected) in listOf("associated" to Outcome.ASSOCIATED, "unprovable" to Outcome.UNPROVABLE, + "gone" to Outcome.GONE, "conflict" to Outcome.CONFLICT, "expired" to Outcome.EXPIRED)) { + val calls = mutableListOf() + assertEquals(expected, RelayPairingProof.afterApprovedListing(context(), rpc = { method, _ -> calls += method; status(name) }, now = { 1_000 })) + assertEquals(listOf(RelayPairingProof.CHALLENGE_METHOD), calls) + } + } + + @Test fun `old host and unacknowledged proof preserve browsing without replaying a write`() = runBlocking { + var calls = 0 + assertEquals(Outcome.UNPROVABLE, RelayPairingProof.afterApprovedListing(context(), rpc = { _, _ -> calls++; throw HostException("Unknown method") }, now = { 1_000 })) + assertEquals(1, calls) + var proofs = 0 + assertEquals(Outcome.UNPROVABLE, RelayPairingProof.afterApprovedListing(context(), rpc = { method, _ -> + if (method == RelayPairingProof.CHALLENGE_METHOD) challenge() else { proofs++; throw HostException("Synthetic lost acknowledgement") } + }, now = { 1_000 })) + assertEquals(1, proofs) + } + + @Test fun `malformed challenge and unknown or negative proof response never report association`() = runBlocking { + for (body in listOf(null, JsonPrimitive(true), buildJsonObject { put("status", "challenge") }, status("unsupported"))) { + var calls = 0 + assertEquals(Outcome.UNPROVABLE, RelayPairingProof.afterApprovedListing(context(), rpc = { _, _ -> calls++; body }, now = { 1_000 })) + assertEquals(1, calls) + } + for ((name, expected) in listOf("gone" to Outcome.GONE, "conflict" to Outcome.CONFLICT, "expired" to Outcome.EXPIRED, "unknown" to Outcome.UNPROVABLE)) { + assertEquals(expected, RelayPairingProof.afterApprovedListing(context(), rpc = { method, _ -> + if (method == RelayPairingProof.CHALLENGE_METHOD) challenge() else status(name) + }, now = { 1_000 })) + } + } + + @Test fun `retired lifetime after challenge before signing or after proof always propagates cancellation`() = runBlocking { + for (retireAt in listOf(1, 2)) { + var current = true; var calls = 0 + val captured = context(current = { if (!current) throw CancellationException("Synthetic retired pairing") }) + assertFailsWith { + RelayPairingProof.afterApprovedListing(captured, rpc = { _, _ -> + calls++; if (calls == retireAt) current = false + if (calls == 1) challenge() else status("associated") + }, now = { 1_000 }) + } + assertEquals(retireAt, calls) + } + } + + @Test fun `final current check after signing prevents a stale proof write`() = runBlocking { + var checks = 0; var proofs = 0 + assertFailsWith { + RelayPairingProof.afterApprovedListing(context(current = { if (++checks == 4) throw CancellationException("Synthetic retired while signing") }), rpc = { method, _ -> + if (method == RelayPairingProof.PROOF_METHOD) proofs++ + challenge() + }, now = { 1_000 }) + } + assertEquals(4, checks); assertEquals(0, proofs) + } + + @Test fun `expiry between verification and submission refuses the proof`() = runBlocking { + var clockReads = 0; var proofs = 0 + assertEquals(Outcome.EXPIRED, RelayPairingProof.afterApprovedListing(context(), rpc = { method, _ -> + if (method == RelayPairingProof.PROOF_METHOD) proofs++ + challenge() + }, now = { if (++clockReads == 1) 1_000 else 61_000 })) + assertEquals(0, proofs) + } + + @Test fun `internal deadline bounds an unanswered challenge without failing approved browsing`() = runBlocking { + var cancelled = false + val result = withTimeoutOrNull(500) { + RelayPairingProof.afterApprovedListing(context(), rpc = { _, _ -> + try { awaitCancellation() } finally { cancelled = true } + }, timeoutMs = 25) + } + assertEquals(Outcome.UNPROVABLE, result, "The internal deadline must finish before this bounded test window") + assertTrue(cancelled) + } + + @Test fun `outer cancellation propagates and releases a suspended proof without a retry`() = runBlocking { + val pending = CompletableDeferred(); var calls = 0; var cancelled = false + val job = async { + assertFailsWith { + RelayPairingProof.afterApprovedListing(context(), rpc = { method, _ -> + calls++ + if (method == RelayPairingProof.CHALLENGE_METHOD) challenge() else { + pending.complete(Unit) + try { awaitCancellation() } finally { cancelled = true } + } + }, now = { 1_000 }) + } + } + pending.await(); job.cancelAndJoin() + assertEquals(2, calls); assertTrue(cancelled) + } + + @Test fun `outer timeout is not mistaken for the optional migration deadline`() = runBlocking { + assertFailsWith { + withTimeout(20) { RelayPairingProof.afterApprovedListing(context(), rpc = { _, _ -> awaitCancellation() }, timeoutMs = 500) } + } + } + +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelaySocketSecurityTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelaySocketSecurityTest.kt new file mode 100644 index 0000000000..1938918fe1 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/RelaySocketSecurityTest.kt @@ -0,0 +1,197 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.crypto.B64 +import dev.nodeterm.protocol.crypto.BoxKeyPair +import dev.nodeterm.protocol.crypto.E2ee +import dev.nodeterm.protocol.relay.RelaySocket +import dev.nodeterm.protocol.relay.RelaySocketListener +import dev.nodeterm.protocol.relay.RelayTransport +import dev.nodeterm.protocol.relay.RelayTransportEvents +import dev.nodeterm.protocol.relay.RelayTransportFactory +import dev.nodeterm.protocol.relay.RpcException +import dev.nodeterm.protocol.relay.RpcUnansweredException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The security rules of docs/ios-protocol-migration.md §2.3–2.4 / §7, pinned on the client with a + * scripted host: no re-key after ready, reflections (our own role) dropped, replays/reorders + * dropped. A regression in any of these is silent in every other test. + */ +class RelaySocketSecurityTest { + /** A hand-driven host end: speaks the handshake with real crypto, lets the test forge frames. */ + private class ScriptedHost(private val hostKeys: BoxKeyPair) : RelayTransportFactory { + lateinit var events: RelayTransportEvents + val fromClient = CopyOnWriteArrayList() + var sessionKey: ByteArray? = null + private var sendSeq = 0L + val hostNonce = E2ee.randomSessionNonce() + + override fun open(url: String, events: RelayTransportEvents): RelayTransport { + this.events = events + return object : RelayTransport { + override fun sendText(text: String): Boolean { + fromClient += text + onClientText(text) + return true + } + + override fun sendBinary(bytes: ByteArray): Boolean { + fromClient += bytes + onClientBinary(bytes) + return true + } + + override fun queuedBytes(): Long = 0 + override fun close() {} + } + } + + private fun onClientText(text: String) { + val o = Json.parseToJsonElement(text).jsonObject + if (o["type"]!!.jsonPrimitive.content != "e2ee_hello") return + val clientPub = o["publicKeyB64"]!!.jsonPrimitive.content + val clientNonce = B64.decode(o["nonceB64"]!!.jsonPrimitive.content)!! + val base = E2ee.deriveSharedKey(clientPub, hostKeys.secretKey) + sessionKey = E2ee.deriveSessionKey(base, hostNonce, clientNonce) + events.onText("""{"type":"e2ee_ready","nonceB64":"${B64.encode(hostNonce)}"}""") + } + + private fun onClientBinary(bytes: ByteArray) { + val plain = E2ee.decrypt(bytes, sessionKey!!) ?: return + val body = String(plain.copyOfRange(10, plain.size)) + if (body.contains("e2ee_auth")) sendRpc("""{"type":"e2ee_authenticated"}""") + } + + fun seal(role: Int, seq: Long, tag: Int, body: ByteArray, key: ByteArray = sessionKey!!): ByteArray { + val b = ByteBuffer.allocate(10 + body.size).order(ByteOrder.LITTLE_ENDIAN) + b.put(role.toByte()).putInt((seq ushr 32).toInt()).putInt(seq.toInt()).put(tag.toByte()).put(body) + return E2ee.encrypt(b.array(), key) + } + + fun sendRpc(json: String, role: Int = 1, seq: Long = sendSeq++) = + events.onBinary(seal(role, seq, 0x01, json.toByteArray())) + } + + private class Recorder : RelaySocketListener { + val notifies = CopyOnWriteArrayList() + var ready: String? = null + override fun onReady(sas: String) { + ready = sas + } + + override fun onNotify(method: String, params: JsonElement?) { + notifies += method + } + } + + private fun connect(): Triple { + val hostKeys = BoxKeyPair.generate() + val host = ScriptedHost(hostKeys) + val rec = Recorder() + val socket = RelaySocket("ws://127.0.0.1:1", "t", BoxKeyPair.generate(), hostKeys.publicKeyB64, host, rec) + assertNotNull(rec.ready, "handshake completes against the scripted host") + return Triple(socket, host, rec) + } + + @Test + fun `hello carries our key and a 16-byte session nonce, and the token rides the URL`() { + val (_, host, _) = connect() + val hello = Json.parseToJsonElement(host.fromClient.first() as String).jsonObject + assertEquals("e2ee_hello", hello["type"]!!.jsonPrimitive.content) + assertEquals(16, B64.decode(hello["nonceB64"]!!.jsonPrimitive.content)!!.size) + assertEquals("wss://r/x?a=1&token=a%2Fb%3D", RelaySocket.withToken("wss://r/x?a=1", "a/b=")) + } + + @Test + fun `a plaintext re-key after ready is ignored and the session keeps working`() { + val (socket, host, rec) = connect() + val sasBefore = socket.sas + val sentBefore = host.fromClient.size + // A MITM re-sends e2ee_ready with its own nonce: must not re-derive or answer. + host.events.onText("""{"type":"e2ee_ready","nonceB64":"${B64.encode(ByteArray(16) { 7 })}"}""") + assertEquals(sentBefore, host.fromClient.size, "no new e2ee_auth was sent") + assertEquals(sasBefore, socket.sas) + host.sendRpc("""{"kind":"notify","method":"canvas:state","params":{}}""") + assertEquals(listOf("canvas:state"), rec.notifies.toList(), "the original session key still decrypts") + } + + @Test + fun `a box carrying our own role is a reflection and is dropped`() { + val (_, host, rec) = connect() + host.sendRpc("""{"kind":"notify","method":"reflected","params":{}}""", role = 2) + assertTrue(rec.notifies.isEmpty()) + } + + @Test + fun `a replayed or reordered seq is dropped`() { + val (_, host, rec) = connect() + host.sendRpc("""{"kind":"notify","method":"first","params":{}}""", seq = 5) + host.sendRpc("""{"kind":"notify","method":"replay","params":{}}""", seq = 5) + host.sendRpc("""{"kind":"notify","method":"older","params":{}}""", seq = 3) + host.sendRpc("""{"kind":"notify","method":"newer","params":{}}""", seq = 6) + assertEquals(listOf("first", "newer"), rec.notifies.toList()) + } + + @Test + fun `a box sealed under another key never reaches the application`() { + val (_, host, rec) = connect() + val forged = host.seal(1, 99, 0x01, """{"kind":"notify","method":"forged","params":{}}""".toByteArray(), key = ByteArray(32) { 1 }) + host.events.onBinary(forged) + assertTrue(rec.notifies.isEmpty()) + } + + /** + * Review of A29: a request that went OUT and got no answer (it timed out, or the socket closed + * while it waited) is told apart from one the host answered with an error, and from one that was + * never sent. The host may have acted on the first (a git commit whose hooks outlasted the wait), + * so the phone must not report it as a failure; the second is the host's own sentence. + */ + @Test + fun `an unanswered request is told apart from an error the host answered`() { + val (socket, host, _) = connect() + fun send(method: String, timeoutMs: Long = 60_000): CompletableFuture> { + val f = CompletableFuture>() + socket.request(method, null, timeoutMs) { f.complete(it) } + return f + } + fun lastRequestId(): String { + val plain = E2ee.decrypt(host.fromClient.last() as ByteArray, host.sessionKey!!)!! + return Json.parseToJsonElement(String(plain.copyOfRange(10, plain.size))).jsonObject["id"]!!.jsonPrimitive.content + } + + val timedOut = send("git.commit", timeoutMs = 50).get(5, TimeUnit.SECONDS).exceptionOrNull() + assertIs(timedOut) + assertEquals("RPC timed out: git.commit", timedOut.message) + + val refused = send("git.status") + host.sendRpc("""{"kind":"res","id":"${lastRequestId()}","ok":false,"body":{"message":"cwd is outside the shared project roots."}}""") + val answered = refused.get(5, TimeUnit.SECONDS).exceptionOrNull() + assertIs(answered) + assertFalse(answered is RpcUnansweredException, "the host answered it") + assertEquals("cwd is outside the shared project roots.", answered.message) + + val waiting = send("git.push") + host.events.onClosed("gone") + val dropped = waiting.get(5, TimeUnit.SECONDS).exceptionOrNull() + assertIs(dropped) + assertEquals("Relay connection closed.", dropped.message) + + val neverSent = send("git.pull").get(5, TimeUnit.SECONDS).exceptionOrNull() + assertIs(neverSent) + assertFalse(neverSent is RpcUnansweredException, "nothing went out") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt new file mode 100644 index 0000000000..e343fd8c27 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/ResumeOfferTest.kt @@ -0,0 +1,275 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ResumeOffer +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.Agent +import dev.nodeterm.protocol.model.Launch +import dev.nodeterm.protocol.model.ManagedAccount +import dev.nodeterm.protocol.model.MirrorSettings +import dev.nodeterm.protocol.model.Pane +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.ProjectsSnapshot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import java.io.File + +/** + * Audit A76 (and A15, and the A41 review): what the terminal screen offers to type after an attach, + * and what it keeps across a reattach. The screen (TerminalController) is only type-checked; these + * pin the rule it delegates to. + */ +class ResumeOfferTest { + private val account = "/Users/me/Library/Application Support/node-terminal/claude-accounts/acct-1" + + /** + * The `projects.list` blob as the desktop serves it: one project with a managed-account Claude + * node in a subfolder, and a mirror entry [entry] for that node (the mirror's own JSON). + */ + private fun snapshot(entry: String?): ProjectsSnapshot = ProjectsParser.parseBlob( + """{"version":2,"projects":[{"id":"p1","name":"Repo","cwd":"/repo","defaultPermissionMode":"plan", + "nodes":[{"id":"n1","kind":"terminal","title":"Claude","agentId":"claude","accountId":"acct-1", + "cwd":"./sub","agentSessionId":"minted-1"}]}]} + ${ProjectsParser.PROJECTS_MARK} + nt-n1 + ${ProjectsParser.STATUS_MARK} + {"v":1,"updatedAt":1,"nodes":{${entry?.let { "\"n1\":$it" } ?: ""}}, + "settings":{"claudePermissionMode":"acceptEdits","autoSupported":true, + "claudeAccounts":[{"id":"acct-1","dir":"$account"}]}}""" + ) + + /** A node Eco put to sleep: the CLI exited (no live state), the flag carries it. */ + private val sleeping = """{"agentId":"claude","sessionId":"sid-7","hibernated":true,"updatedAt":5}""" + private val awake = """{"state":"done","agentId":"claude","sessionId":"sid-7","updatedAt":5}""" + + @Test + fun `a cold attach offers the cold-restore resume line (A15)`() { + // Only the relay creates a session (an SSH attach is never fresh); the rule itself does not + // look at the transport. + for (transport in TransportKind.entries) { + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = true, transport, snapshot(awake), "n1")) + assertEquals(ResumeOffer.Kind.RESUME, offer.kind) + assertEquals( + "cd '/repo/sub' && CLAUDE_CONFIG_DIR='$account' claude --resume sid-7 --permission-mode plan", + offer.command + ) + assertEquals(offer.command + "\r", offer.keys, "a pane created just now has no half-typed line to clear") + assertEquals("Resume", offer.button) + } + } + + /** What the pane's foreground command reads while Eco's shell owns it (a login shell). */ + private val shell = "-zsh" + + @Test + fun `a Sleeping session opened over SSH offers the desktop's wake line (A76)`() { + assertTrue(ResumeOffer.wantsPane(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1"), "the pane is read first") + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", paneCommand = shell)) + assertEquals(ResumeOffer.Kind.WAKE, offer.kind) + // No cd and no account prefix, although the node has both: the pane's shell is the one the CLI + // exited back to, already in the node's folder and under the account's env. + assertEquals("claude --resume sid-7 --permission-mode plan", offer.command) + assertEquals("\u0015claude --resume sid-7 --permission-mode plan\r", offer.keys, "the prompt's line is cleared first") + assertEquals("Wake Claude Code", offer.button) + assertTrue("sleeping" in offer.message) + } + + @Test + fun `a Sleeping session opened through the relay offers nothing, since the desktop wakes it on attach`() { + // host-service reports the attach (`remoteViewer.attached` → `agent:wake`); an offer as well + // would type `--resume` into the CLI the desktop just started. + assertFalse(ResumeOffer.wantsPane(fresh = false, TransportKind.RELAY, snapshot(sleeping), "n1")) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(sleeping), "n1", paneCommand = shell)) + } + + @Test + fun `a warm attach of an awake session offers nothing, on either transport`() { + for (transport in TransportKind.entries) { + assertFalse(ResumeOffer.wantsPane(fresh = false, transport, snapshot(awake), "n1"), "no pane read for a node that is awake") + assertNull(ResumeOffer.afterAttach(fresh = false, transport, snapshot(awake), "n1", paneCommand = shell)) + assertNull(ResumeOffer.afterAttach(fresh = false, transport, snapshot(null), "n1", paneCommand = shell)) + } + } + + @Test + fun `a cold pane wins over a stale Sleeping flag`() { + // The shell the CLI exited to went with the old tmux session; the new pane needs the full line. + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(sleeping), "n1")) + assertEquals(ResumeOffer.Kind.RESUME, offer.kind) + assertTrue(offer.command.startsWith("cd '/repo/sub' && ")) + } + + @Test + fun `a paused node is only ever offered, and a deep pause offers nothing`() { + // A shallow "Pause session" is an Eco exit plus `paused`, and the mirror carries only the + // `hibernated` half: the phone sees Sleeping. The desktop never wakes a paused node on its own, + // but its PAUSED chip is an explicit Resume, and a tapped offer is that same explicit Resume. + val shallow = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", paneCommand = shell)) + assertEquals(ResumeOffer.Kind.WAKE, shallow.kind) + // "Pause & end session" recycles the tmux session and leaves `hibernated` unset: no offer. + val deep = """{"agentId":"claude","sessionId":"sid-7","updatedAt":5}""" + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(deep), "n1", paneCommand = shell)) + } + + @Test + fun `a Sleeping entry with no agent or session of its own falls back to the node's`() { + // `setNodeHibernated` can create an entry that carries only the flag. + val bare = """{"hibernated":true,"updatedAt":5}""" + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(bare), "n1", paneCommand = "bash")) + assertEquals("claude --resume minted-1 --permission-mode plan", offer.command) + } + + @Test + fun `no offer for an id that cannot reach a command line, or an agent the phone cannot resume`() { + val unsafe = """{"agentId":"claude","sessionId":"x; rm -rf ~","hibernated":true,"updatedAt":5}""" + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(unsafe), "n1", paneCommand = shell)) + val custom = ProjectsParser.parseBlob( + """{"version":2,"projects":[{"id":"p1","name":"R","nodes":[{"id":"c1","agentId":"custom:abc","agentSessionId":"s"}]}]} + ${ProjectsParser.STATUS_MARK} + {"nodes":{"c1":{"hibernated":true,"updatedAt":1}}}""" + ) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, custom, "c1", paneCommand = shell)) + } + + @Test + fun `a wake is withdrawn at the tap once the node is no longer Sleeping`() { + val offer = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", paneCommand = shell)) + assertTrue(offer.stillOffered(snapshot(sleeping), "n1", shell)) + // The desktop resumed it meanwhile, and its first live hook event cleared the flag. + assertFalse(offer.stillOffered(snapshot(awake), "n1", shell)) + // Or the flag is still set, but a CLI owns the pane now (the desktop woke it and its hook events + // have not reached the phone yet, or a resume was typed by hand): nothing is typed into it. + assertFalse(offer.stillOffered(snapshot(sleeping), "n1", "claude")) + assertFalse(offer.stillOffered(snapshot(sleeping), "n1", null), "a pane that could not be read withdraws it") + val resume = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(awake), "n1")) + assertTrue(resume.stillOffered(snapshot(awake), "n1"), "a cold pane's resume does not depend on the flag") + } + + /** + * The A76 review. The mirror's `hibernated` flag can outlive the sleep: a desktop older than the + * review dropped its own copy on a resumed CLI's first live hook event without telling the mirror + * (codex reports its start only that way), and with the desktop app not running nothing hears the + * resumed CLI at all. So after the phone wakes a codex session, the node still reads Sleeping, and + * every later open would offer `codex resume` into the running CLI, as a prompt. + */ + @Test + fun `a Sleeping flag over a running CLI offers no wake, so a phone-woken codex is not offered again`() { + val codex = ProjectsParser.parseBlob( + """{"version":2,"projects":[{"id":"p1","name":"Repo","cwd":"/repo", + "nodes":[{"id":"x1","kind":"terminal","agentId":"codex","cwd":"./sub"}]}]} + ${ProjectsParser.STATUS_MARK} + {"nodes":{"x1":{"state":"done","agentId":"codex","sessionId":"t-1","hibernated":true,"updatedAt":5}}}""" + ) + // Eco left a shell in the pane: the wake is offered, and the phone types it. + val wake = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1", paneCommand = "bash")) + assertEquals("\u0015codex resume t-1\r", wake.keys) + // Codex is running there now, and nothing has cleared the flag: a reattach (the stream dropped, + // the app came back) and a new open both find Sleeping in the listing, and a CLI in the pane. + assertTrue(ResumeOffer.wantsPane(fresh = false, TransportKind.SSH, codex, "x1")) + for (running in listOf("codex", "node", "/usr/local/bin/codex")) { + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1", paneCommand = running), running) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1", carried = wake, paneCommand = running), running) + assertFalse(wake.stillOffered(codex, "x1", running), running) + } + // A pane that could not be read, and a shell the desktop's list does not know, offer nothing + // either: an unknown owner is never typed into. + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1")) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1", paneCommand = "nu")) + // Once the CLI exits back to its shell, the conversation can be woken again. + assertEquals(wake, ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, codex, "x1", paneCommand = "-bash")) + } + + /** The screen is only type-checked: pin that it feeds the pane read into both decisions. */ + @Test + fun `the terminal screen reads the pane before it offers a wake, and again before it types one`() { + val controller = AppSourcePins.ui("TerminalController.kt") + val attach = AppSourcePins.blockAfter(controller, "private suspend fun afterAttach(") + AppSourcePins.assertInOrder(attach, "ResumeOffer.wantsPane(", "conn.paneCommand(nodeId)", "ResumeOffer.afterAttach(", "paneCommand = pane") + val tap = AppSourcePins.blockAfter(controller, "fun acceptResume()") + AppSourcePins.assertInOrder(tap, "ResumeOffer.Kind.WAKE", "conn?.paneCommand(nodeId)", "offer.stillOffered(snap, nodeId, pane)", "writeAfterScrollCancel(offer.keys, s)") + } + + @Test + fun `the pane gate is the desktop's shell list`() { + for (sh in listOf("zsh", "-zsh", "bash", "/bin/sh", "fish", "dash", "ksh", "tcsh")) assertTrue(Pane.isShell(sh), sh) + for (other in listOf(null, "", "node", "claude", "codex", "sleep", "nu", "pwsh", "zsh/", "-")) assertFalse(Pane.isShell(other), "$other") + // Pinned against the desktop's own list (src/shared/agents/pane.ts `SHELLS`), so the phone's + // wake and the desktop's wake cannot disagree about what a Sleeping pane looks like. + val source = File(InteropHarness.repoRoot, "src/shared/agents/pane.ts").readText() + val list = assertNotNull(Regex("const SHELLS = new Set\\(\\[([^\\]]*)\\]\\)").find(source), "SHELLS in pane.ts").groupValues[1] + assertEquals(Regex("'([^']+)'").findAll(list).map { it.groupValues[1] }.toSet(), Pane.SHELLS) + } + + /** + * The A41 review: a reattach of the same screen (the stream dropped, the app went to the + * background) is warm, because the cold attach before it created the session. Re-deriving the + * offer from `fresh` alone dropped an unanswered resume on every reconnect. + */ + @Test + fun `an unanswered resume is kept through a warm reattach of the pane it was made for`() { + val offered = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(awake), "n1")) + for (transport in TransportKind.entries) { + assertEquals(offered, ResumeOffer.afterAttach(fresh = false, transport, snapshot(awake), "n1", carried = offered)) + } + // Nothing carried (answered, dismissed, or never offered): a warm attach still offers nothing. + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(awake), "n1", carried = null)) + } + + @Test + fun `a carried resume still wins over a stale Sleeping flag`() { + // The pane is the cold one the first attach created, not the shell a CLI exited back to, so + // the wake line (no cd, no account) would still be the wrong one there. + val offered = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(sleeping), "n1")) + val kept = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", carried = offered, paneCommand = shell)) + assertEquals(ResumeOffer.Kind.RESUME, kept.kind) + assertEquals(offered, kept) + } + + @Test + fun `a carried resume is dropped once the computer describes something else`() { + val offered = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(awake), "n1")) + // A hook event from the node since the offer: a CLI ran in that pane while the phone was away + // (say, resumed on the computer), and the resume line would reach it as a prompt. + val heard = """{"state":"done","agentId":"claude","sessionId":"sid-7","updatedAt":9}""" + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(heard), "n1", carried = offered)) + // Another conversation. + val other = """{"state":"done","agentId":"claude","sessionId":"sid-8","updatedAt":5}""" + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(other), "n1", carried = offered)) + // The node is gone from the canvas and the mirror. + val gone = ProjectsParser.parseBlob("""{"version":2,"projects":[{"id":"p1","name":"Repo","cwd":"/repo","nodes":[]}]}""") + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, gone, "n1", carried = offered)) + } + + @Test + fun `a fresh reattach is a new cold pane and gets the line built now`() { + val offered = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(awake), "n1")) + val other = """{"state":"done","agentId":"claude","sessionId":"sid-8","updatedAt":9}""" + val rebuilt = assertNotNull(ResumeOffer.afterAttach(fresh = true, TransportKind.RELAY, snapshot(other), "n1", carried = offered)) + assertEquals(ResumeOffer.Kind.RESUME, rebuilt.kind) + assertTrue(rebuilt.command.contains("--resume sid-8"), rebuilt.command) + } + + @Test + fun `a wake is not carried, since each attach re-derives it`() { + val wake = assertNotNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", paneCommand = shell)) + assertEquals(wake, ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(sleeping), "n1", carried = wake, paneCommand = shell)) + // Woken on the computer meanwhile, or reopened through the relay (whose attach wakes it). + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.SSH, snapshot(awake), "n1", carried = wake, paneCommand = shell)) + assertNull(ResumeOffer.afterAttach(fresh = false, TransportKind.RELAY, snapshot(sleeping), "n1", carried = wake, paneCommand = shell)) + } + + @Test + fun `the wake line is the resume with the permission mode and nothing else`() { + val s = MirrorSettings("auto", autoSupported = false, claudeAccounts = listOf(ManagedAccount("a1", account)), codexApprovalValues = emptyList()) + assertEquals("claude --resume abc", Launch.wakeLine(Agent.CLAUDE, "abc", s), "auto on an old claude degrades to no flag") + assertEquals("claude --resume abc --permission-mode auto", Launch.wakeLine(Agent.CLAUDE, "abc", s.copy(autoSupported = true))) + assertEquals("claude --resume abc --permission-mode plan", Launch.wakeLine(Agent.CLAUDE, "abc", s, "plan")) + // Unsupported modes stay at the CLI's default; supported modes use its own dialect. + assertEquals("codex resume abc", Launch.wakeLine(Agent.CODEX, "abc", s, "plan"), "Codex cannot express plan") + assertEquals("gemini --resume abc --approval-mode plan", Launch.wakeLine(Agent.GEMINI, "abc", s, "plan")) + assertNull(Launch.wakeLine(Agent.CLAUDE, "-flag", s)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretPresenceTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretPresenceTest.kt new file mode 100644 index 0000000000..09195c6598 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretPresenceTest.kt @@ -0,0 +1,143 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.secure.SecretStorage +import dev.nodeterm.protocol.secure.SecretStoreCore +import dev.nodeterm.protocol.secure.SecretUnavailableException +import dev.nodeterm.protocol.secure.Sealer +import java.security.ProviderException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Audit A47: the host list decided "From anywhere" by DECRYPTING each computer's relay token — an + * Android Keystore round trip on the main thread, per row, per recomposition, and one that waited + * on the store's lock behind any connection decrypting at the same moment. The list only needs to + * know a token is stored. [SecretStoreCore.contains] answers that from the storage alone, and + * [SecretStoreCore.revision] tells the list when to ask again. + * + * The Compose row itself cannot run on a JVM, so its wiring is pinned in the app's source + * ([AppSourcePins]); nothing here has run on a device. + */ +class SecretPresenceTest { + private class MemStorage : SecretStorage { + val map = HashMap() + override fun get(name: String) = map[name] + override fun put(name: String, value: String, durable: Boolean) { + map[name] = value + } + override fun remove(name: String) { + map.remove(name) + } + } + + /** A reversible stand-in cipher that counts opens and can fail or block like a busy keystore. */ + private class CountingSealer : Sealer { + var opens = 0 + var failOpenWith: Exception? = null + var onOpen: (() -> Unit)? = null + override fun seal(plain: ByteArray): ByteArray = ByteArray(12) + plain.map { (it.toInt() xor 0x5a).toByte() } + ByteArray(16) + override fun open(sealed: ByteArray): ByteArray { + opens++ + onOpen?.invoke() + failOpenWith?.let { throw it } + return sealed.copyOfRange(12, sealed.size - 16).map { (it.toInt() xor 0x5a).toByte() }.toByteArray() + } + } + + private val storage = MemStorage() + private val sealer = CountingSealer() + private val store = SecretStoreCore(storage, sealer) + private val token = "relay.token.h1" + + @Test + fun `presence is answered without opening the value`() { + assertFalse(store.contains(token)) + store.putBytes(token, "tok".toByteArray()) + assertTrue(store.contains(token)) + assertEquals(0, sealer.opens, "contains opened the stored value") + store.remove(token) + assertFalse(store.contains(token)) + assertEquals(0, sealer.opens) + } + + @Test + fun `a token the keystore cannot open right now is still stored`() { + // The old check read "no relay" during a keystore hiccup; the token is still there. + store.putBytes(token, "tok".toByteArray()) + sealer.failOpenWith = ProviderException("Keystore operation failed") + assertFailsWith { store.getBytes(token) } + assertTrue(store.contains(token)) + } + + @Test + fun `presence does not wait behind a decrypt on another thread`() { + store.putBytes(token, "tok".toByteArray()) + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + sealer.onOpen = { + entered.countDown() + release.await(10, TimeUnit.SECONDS) + } + // A connection decrypting the token, holding the store's lock for as long as the keystore takes. + val reader = Thread { runCatching { store.getBytes(token) } }.apply { start() } + val ui = Executors.newSingleThreadExecutor() + try { + assertTrue(entered.await(5, TimeUnit.SECONDS), "the reader never reached the keystore") + val answer = ui.submit { store.contains(token) } + // Throws TimeoutException when contains waits for the lock the reader holds. + assertTrue(answer.get(2, TimeUnit.SECONDS)) + } finally { + release.countDown() + reader.join(5_000) + ui.shutdownNow() + } + } + + @Test + fun `the revision moves when a secret is stored or removed, never when one is read`() { + val start = store.revision.value + store.putBytes(token, "tok".toByteArray()) + val afterPut = store.revision.value + assertTrue(afterPut > start, "storing a token did not move the revision") + + store.getBytes(token) + store.read(token) + store.contains(token) + assertEquals(afterPut, store.revision.value, "a read moved the revision") + + store.remove(token) + val afterRemove = store.revision.value + assertTrue(afterRemove > afterPut, "removing a token did not move the revision") + + store.getOrCreate32("box.secret") + val afterCreate = store.revision.value + assertTrue(afterCreate > afterRemove, "creating the identity did not move the revision") + store.getOrCreate32("box.secret") + assertEquals(afterCreate, store.revision.value, "reading back the identity moved the revision") + } + + private val hostsScreen get() = AppSourcePins.ui("HostsScreen.kt") + + @Test + fun `the host list asks whether a token is stored, never for its value`() { + val src = hostsScreen + assertFalse(src.contains("secure.getString"), "HostsScreen decrypts a secret") + assertFalse(src.contains("secure.getBytes"), "HostsScreen decrypts a secret") + // The row re-asks when the store's revision moves: a late relay adoption can mint a token + // without changing the host record, so the host list alone would not recompose the row. + assertTrue(src.contains("val secretsRevision by graph.secure.revision.collectAsState()")) + assertTrue(src.contains("items(hosts, key = { it.id }) { host ->")) + val row = AppSourcePins.blockAfter(src, "}) { host ->") // the row's body, not the key lambda + AppSourcePins.assertInOrder( + row, + "val relayTokenStored = remember(host.id, secretsRevision) { graph.secure.hasRelayToken(host.id) }", + "routeSummary(host, relayTokenStored)" + ) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretStoreCoreTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretStoreCoreTest.kt new file mode 100644 index 0000000000..f3a50eddc6 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SecretStoreCoreTest.kt @@ -0,0 +1,104 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.secure.SecretStorage +import dev.nodeterm.protocol.secure.SecretStoreCore +import dev.nodeterm.protocol.secure.SecretUnavailableException +import dev.nodeterm.protocol.secure.Sealer +import java.security.ProviderException +import java.security.SecureRandom +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotEquals + +/** Audit A24: the phone's identity is replaced only on positive evidence it is gone. */ +class SecretStoreCoreTest { + private class MemStorage : SecretStorage { + val map = HashMap() + val durableWrites = ArrayList() + override fun get(name: String) = map[name] + override fun put(name: String, value: String, durable: Boolean) { + map[name] = value + if (durable) durableWrites += name + } + override fun remove(name: String) { + map.remove(name) + } + } + + /** Real AES-GCM (the Keystore does the same), plus a switch to fail like a busy keystore. */ + private class GcmSealer(var key: SecretKey = newKey()) : Sealer { + var failOpenWith: Exception? = null + override fun seal(plain: ByteArray): ByteArray { + val iv = ByteArray(12).also { SecureRandom().nextBytes(it) } + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv)) + return iv + c.doFinal(plain) + } + override fun open(sealed: ByteArray): ByteArray { + failOpenWith?.let { throw it } + val c = Cipher.getInstance("AES/GCM/NoPadding") + c.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed, 0, 12)) + return c.doFinal(sealed, 12, sealed.size - 12) + } + companion object { + fun newKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + } + } + + private val storage = MemStorage() + private val sealer = GcmSealer() + private val store = SecretStoreCore(storage, sealer) + + @Test + fun `the identity is created once, durably, and then kept`() { + val a = store.getOrCreate32("box.secret") + assertEquals(listOf("box.secret"), storage.durableWrites) + assertContentEquals(a, store.getOrCreate32("box.secret")) + } + + @Test + fun `a transient keystore failure never overwrites the identity`() { + val a = store.getOrCreate32("box.secret") + val before = storage.map["box.secret"] + sealer.failOpenWith = ProviderException("Keystore operation failed") + assertFailsWith { store.getOrCreate32("box.secret") } + sealer.failOpenWith = IllegalStateException("keystore locked") + assertFailsWith { store.getBytes("box.secret") } + assertEquals(before, storage.map["box.secret"], "the stored identity was not touched") + sealer.failOpenWith = null + assertContentEquals(a, store.getOrCreate32("box.secret"), "and it reads back once the keystore answers") + } + + @Test + fun `a value sealed under a key that is gone is replaced (positive evidence)`() { + val a = store.getOrCreate32("ssh.seed") + sealer.key = GcmSealer.newKey() // the Keystore key was wiped and regenerated + assertIs(store.read("ssh.seed")) + assertNotEquals(a.toList(), store.getOrCreate32("ssh.seed").toList()) + } + + @Test + fun `a malformed blob is lost, not unavailable`() { + storage.map["x"] = "%%%not base64%%%" + assertIs(store.read("x")) + storage.map["y"] = java.util.Base64.getEncoder().encodeToString(ByteArray(5)) + assertIs(store.read("y")) + assertEquals(32, store.getOrCreate32("x").size) + } + + @Test + fun `strings round-trip and absence is absence`() { + assertIs(store.read("relay.token.h")) + store.putBytes("relay.token.h", "tok".toByteArray()) + assertEquals("tok", store.getBytes("relay.token.h")!!.toString(Charsets.UTF_8)) + assertIs(runCatching { sealer.key = GcmSealer.newKey(); sealer.open(java.util.Base64.getDecoder().decode(storage.map["relay.token.h"])) }.exceptionOrNull()) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SeenLogTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SeenLogTest.kt new file mode 100644 index 0000000000..e1a2998e6c --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SeenLogTest.kt @@ -0,0 +1,517 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.OnScreen +import dev.nodeterm.protocol.model.SeenLog +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A48: each Inbox event is announced once — no hash-order eviction, no lost update. Its + * follow-up: the log is kept per computer, since two computers can mint the same event id, while one + * event that reaches the phone through two pairings (a desktop and the SSH host it drives) is still + * announced once. + */ +class SeenLogTest { + private class MemStorage( + var raw: String? = null, + var v2: String? = null, + var legacy: Set? = null + ) : SeenLog.Storage { + var writes = 0 + override fun read(): String? = raw + override fun readV2(): String? = v2 + override fun readLegacy(): Set? = legacy + override fun write(encoded: String) { + raw = encoded + v2 = null + legacy = null + writes++ + } + } + + private class Clock(var now: Long) : () -> Long { + override fun invoke(): Long = now + } + + private val hour = 3_600_000L + private val t0 = 1_800_000_000_000L + + /** The computer most cases run on, and a second one. */ + private val h = "host-a" + private val other = "host-b" + + private fun ev(id: String, ts: Long, resolved: Boolean = false, kind: InboxKind = InboxKind.APPROVAL, node: String = "n1") = InboxEvent( + id = id, ts = ts, nodeId = node, agentId = "claude", sessionId = null, kind = kind, title = "t", + detail = null, interrupted = false, resolved = resolved, options = emptyList(), multiSelect = false, pendingId = null + ) + + private fun stored(storage: MemStorage, now: Long): SeenLog.Entries = SeenLog.decode(storage.raw ?: "{}", now) + + private fun ids(events: List) = events.map { it.id } + + // --- Per computer ------------------------------------------------------------------------------ + + @Test + fun `the same event id from two computers is announced for each, once`() { + // Desktop ids are `${ts}-${seq}` with a per-run seq: two computers mint `-3` alike, each + // for a node of its own. + val clock = Clock(t0) + val log = SeenLog(MemStorage(), clock) + val mine = ev("$t0-3", t0, node = "n1") + val theirs = ev("$t0-3", t0, node = "n2") + assertEquals(listOf(mine.id), ids(log.claimAnnounceable(h, listOf(mine)))) + assertEquals(listOf(theirs.id), ids(log.claimAnnounceable(other, listOf(theirs))), "one computer's event silenced another's") + clock.now += 60_000 + assertEquals(emptyList(), log.claimAnnounceable(h, listOf(mine))) + assertEquals(emptyList(), log.claimAnnounceable(other, listOf(theirs))) + } + + @Test + fun `the live check, too, decides each computer on its own`() { + val log = SeenLog(MemStorage(), Clock(t0)) + val mine = ev("$t0-1", t0, node = "n1") + val theirs = ev("$t0-1", t0, node = "n2") + // On screen on one computer (its Inbox tab), off screen on the other. + assertEquals(emptyList(), log.claimLive(h, listOf(mine), OnScreen(inbox = true), notify = true)) + assertEquals(listOf(theirs.id), ids(log.claimLive(other, listOf(theirs), OnScreen.NOTHING, notify = true))) + assertTrue(log.isSeen(h, mine) && log.isSeen(other, theirs)) + } + + @Test + fun `reading an event on one computer does not mark another's`() { + val log = SeenLog(MemStorage(), Clock(t0)) + log.markSeen(h, listOf(ev("e", t0, node = "n1"))) + assertTrue(log.isSeen(h, ev("e", t0, node = "n1"))) + assertFalse(log.isSeen(other, ev("e", t0, node = "n2"))) + assertEquals(listOf("e"), ids(log.claimAnnounceable(other, listOf(ev("e", t0, node = "n2"))))) + } + + // --- One event, two pairings --------------------------------------------------------------------- + + @Test + fun `one event reached through two pairings is announced once, by either check`() { + // A desktop's listing carries its SSH project's node, and so does the slice it pushes to the + // SSH host the phone also added (audit A27): the same id, the same node, under both. + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + val event = ev("$t0-7", t0, node = "ssh-node") + assertEquals(listOf(event.id), ids(log.claimAnnounceable(h, listOf(event)))) + assertEquals(emptyList(), log.claimAnnounceable(other, listOf(event)), "announced a second time under the other pairing") + assertEquals(emptyList(), log.claimLive(other, listOf(event), OnScreen.NOTHING, notify = true)) + // The other way round, through the live check. + val next = ev("$t0-8", t0, node = "ssh-node") + assertEquals(listOf(next.id), ids(log.claimLive(other, listOf(next), OnScreen.NOTHING, notify = true))) + assertEquals(emptyList(), log.claimAnnounceable(h, listOf(next))) + // Met that way, it is recorded under the other pairing too: forgetting the first one does + // not announce it again. + assertEquals(setOf(event.id, next.id), stored(storage, t0).ids(other)) + assertEquals(setOf(event.id, next.id), stored(storage, t0).ids(h)) + log.forgetHost(h) + clock.now += 60_000 + assertEquals(emptyList(), log.claimAnnounceable(other, listOf(event, next))) + } + + @Test + fun `reading or seeing one event under one pairing keeps the other pairing quiet`() { + val log = SeenLog(MemStorage(), Clock(t0)) + // Read on the desktop's Inbox tab. + log.markSeen(h, listOf(ev("read", t0, node = "ssh-node"))) + assertTrue(log.isSeen(other, ev("read", t0, node = "ssh-node"))) + assertEquals(emptyList(), log.claimAnnounceable(other, listOf(ev("read", t0, node = "ssh-node")))) + // Its pane open from the desktop (HostSession.notePaneShown). + log.claimLive(h, listOf(ev("pane", t0, node = "ssh-node")), OnScreen(nodes = setOf("ssh-node")), notify = false) + assertEquals(emptyList(), log.claimLive(other, listOf(ev("pane", t0, node = "ssh-node")), OnScreen.NOTHING, notify = true)) + // On the Inbox tab of the other pairing: recorded there, so neither announces it. + log.claimLive(other, listOf(ev("tab", t0, node = "ssh-node")), OnScreen(inbox = true), notify = true) + assertEquals(emptyList(), log.claimAnnounceable(h, listOf(ev("tab", t0, node = "ssh-node")))) + } + + @Test + fun `an entry that names no node counts under its own computer only`() { + // As the build before nodes were kept wrote it: a bare time under the computer. + val storage = MemStorage(raw = """{"hosts":{"host-a":{"x":$t0}}}""") + val log = SeenLog(storage, Clock(t0)) + assertTrue(log.isSeen(h, ev("x", t0))) + assertFalse(log.isSeen(other, ev("x", t0))) + assertEquals(listOf("x"), ids(log.claimAnnounceable(other, listOf(ev("x", t0))))) + assertEquals(SeenLog.Seen(t0, null), stored(storage, t0).hosts[h]?.get("x")) + } + + @Test + fun `forgetting a computer drops its entries and only its own`() { + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + log.markSeen(h, listOf(ev("a", t0))) + log.markSeen(other, listOf(ev("b", t0))) + log.forgetHost(h) + assertEquals(emptySet(), stored(storage, t0).ids(h)) + assertNull(stored(storage, t0).hosts[h], "a forgotten computer keeps an empty part") + assertEquals(setOf("b"), stored(storage, t0).ids(other)) + // Forgetting what is not there writes nothing. + val writes = storage.writes + log.forgetHost("never-paired") + log.forgetHost(h) + assertEquals(writes, storage.writes) + } + + @Test + fun `a computer paired again keeps what the phone saw of it, the later anchor winning`() { + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + log.markSeen(other, listOf(ev("both", t0), ev("theirs", t0))) + log.markSeen(h, listOf(ev("a", t0))) + clock.now = t0 + hour + log.markSeen(h, listOf(ev("both", t0))) + log.moveHost(h, other) + assertEquals(emptyList(), log.claimAnnounceable(other, listOf(ev("a", t0), ev("both", t0)))) + assertEquals?>( + mapOf("both" to t0 + hour, "theirs" to t0, "a" to t0), + stored(storage, clock.now).anchors(other) + ) + assertNull(stored(storage, clock.now).hosts[h]) + // Onto itself, or from nothing: no change, nothing written. + val writes = storage.writes + log.moveHost(other, other) + log.moveHost("gone", other) + assertEquals(writes, storage.writes) + assertEquals(setOf("a", "both", "theirs"), stored(storage, clock.now).ids(other)) + } + + @Test + fun `a computer paired again keeps the node an entry names`() { + // The new pairing's entry for `x` names no node (written before nodes were kept), the old + // one's does: the merged entry keeps it, so the other pairing still recognises the event. + val storage = MemStorage(raw = """{"hosts":{"old":{"x":{"t":${t0 + hour},"n":"n7"}},"new":{"x":$t0}}}""") + val log = SeenLog(storage, Clock(t0)) + log.moveHost("old", "new") + assertEquals(SeenLog.Seen(t0 + hour, "n7"), stored(storage, t0).hosts["new"]?.get("x")) + assertTrue(log.isSeen(other, ev("x", t0, node = "n7"))) + } + + // --- Migration ----------------------------------------------------------------------------------- + + @Test + fun `the phone-wide A48 log migrates as seen for every computer, announces nothing, then ages out`() { + val clock = Clock(t0) + val storage = MemStorage(v2 = SeenLog.encodeIds(mapOf("a" to t0 - hour, "b" to t0)).toString(), legacy = setOf("old")) + val log = SeenLog(storage, clock) + val feed = listOf(ev("a", t0 - hour), ev("b", t0), ev("c", t0)) + assertEquals(listOf("c"), ids(log.claimAnnounceable(h, feed))) + // Another computer's events of the same ids (its own nodes): the shared ones count for it too. + val theirs = feed.map { it.copy(nodeId = "n2") } + assertEquals(listOf("c"), ids(log.claimAnnounceable(other, theirs)), "an upgrade re-announced an event the phone had seen") + assertNull(storage.v2, "the A48 log is dropped in the same write") + assertNull(storage.legacy) + val after = stored(storage, t0) + assertEquals(mapOf("a" to t0 - hour, "b" to t0), after.shared, "the anchors carry over unchanged") + assertEquals(setOf("c"), after.ids(h)) + // New records go to their computer, never to the shared part. + log.markSeen(h, listOf(ev("a", t0))) + assertEquals(setOf("a", "c"), stored(storage, t0).ids(h)) + assertEquals(setOf("a", "b"), stored(storage, t0).shared.keys) + // Forgetting a computer cannot drop the shared entries, which belong to no computer. + log.forgetHost(other) + assertTrue(log.isSeen(other, ev("b", t0))) + // They age out on their own anchors: `a` one retention period after t0 - 1 h, `b` after t0. + clock.now = t0 - hour + SeenLog.RETENTION_MS + 1 + log.markSeen(h, listOf(ev("d", clock.now))) + assertEquals(setOf("b"), stored(storage, clock.now).shared.keys) + assertTrue(log.isSeen(h, ev("a", t0)), "its own entry outlives the shared one") + assertFalse(log.isSeen(other, ev("a", t0, node = "n2"))) + clock.now = t0 + SeenLog.RETENTION_MS + 1 + log.markSeen(h, listOf(ev("e", clock.now))) + assertEquals(emptyMap(), stored(storage, clock.now).shared) + assertFalse(storage.raw!!.contains("shared"), "an empty shared part is left out") + } + + @Test + fun `the pre-A48 id set migrates as seen now for every computer, announces nothing, then ages out`() { + val clock = Clock(t0) + val storage = MemStorage(legacy = setOf("a", "b")) + val log = SeenLog(storage, clock) + val feed = listOf(ev("a", t0 - hour), ev("b", t0 - 2 * hour), ev("c", t0)) + assertEquals(listOf("c"), ids(log.claimAnnounceable(h, feed))) + assertEquals(listOf("c"), ids(log.claimAnnounceable(other, feed.map { it.copy(nodeId = "n2") }))) + assertNull(storage.legacy, "the legacy set is dropped in the same write") + assertEquals(mapOf("a" to t0, "b" to t0), stored(storage, t0).shared) + assertEquals?>(mapOf("c" to t0), stored(storage, t0).anchors(h)) + clock.now = t0 + SeenLog.RETENTION_MS + 1 + log.markSeen(h, listOf(ev("d", clock.now))) + assertEquals(emptyMap(), stored(storage, clock.now).shared) + assertEquals(setOf("d"), stored(storage, clock.now).ids(h)) + } + + @Test + fun `an older log is migrated by a read too, once`() { + val storage = MemStorage(v2 = """{"a":$t0}""") + val log = SeenLog(storage, Clock(t0)) + assertTrue(log.isSeen(h, ev("a", t0))) + assertTrue(log.isSeen(other, ev("a", t0))) + assertEquals(1, storage.writes) + assertNull(storage.v2) + val legacy = MemStorage(legacy = setOf("a")) + val fromSet = SeenLog(legacy, Clock(t0)) + assertTrue(fromSet.isSeen(h, ev("a", t0))) + assertTrue(fromSet.isSeen(h, ev("a", t0))) + assertEquals(1, legacy.writes) + assertNull(legacy.legacy) + } + + @Test + fun `the current log wins over an older one left beside it`() { + val storage = MemStorage(raw = """{"hosts":{"host-a":{"x":$t0}}}""", v2 = """{"y":$t0}""", legacy = setOf("z")) + val log = SeenLog(storage, Clock(t0)) + assertTrue(log.isSeen(h, ev("x", t0))) + assertFalse(log.isSeen(h, ev("y", t0))) + assertFalse(log.isSeen(h, ev("z", t0))) + assertEquals(0, storage.writes) + } + + // --- A48: what is kept, and for how long ---------------------------------------------------------- + + @Test + fun `an event is claimed once, and a read one is never claimed`() { + val clock = Clock(t0) + val log = SeenLog(MemStorage(), clock) + log.markSeen(h, listOf(ev("read", t0 - hour))) + val feed = listOf(ev("read", t0 - hour), ev("a", t0 - hour), ev("b", t0)) + assertEquals(listOf("a", "b"), ids(log.claimAnnounceable(h, feed))) + clock.now += 60_000 + assertEquals(emptyList(), log.claimAnnounceable(h, feed)) + assertTrue(log.isSeen(h, ev("a", t0)) && log.isSeen(h, ev("b", t0)) && log.isSeen(h, ev("read", t0))) + } + + @Test + fun `only unresolved events inside the announce window are claimed`() { + val log = SeenLog(MemStorage(), Clock(t0)) + val feed = listOf( + ev("old", t0 - SeenLog.ANNOUNCE_WINDOW_MS), + ev("resolved", t0 - hour, resolved = true), + ev("recent", t0 - SeenLog.ANNOUNCE_WINDOW_MS + 1), + ev("ahead", t0 + 3 * hour), + ev("ancient", Long.MIN_VALUE) + ) + assertEquals(listOf("recent", "ahead"), ids(log.claimAnnounceable(h, feed))) + assertFalse(log.isSeen(h, ev("old", t0))) + assertFalse(log.isSeen(h, ev("resolved", t0))) + } + + @Test + fun `a busy phone never evicts an event that could still be announced`() { + // The old trim kept `takeLast(500)` of a HashSet: the 501st id evicted an arbitrary one. + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + val first = ev("first", t0) + assertEquals(1, log.claimAnnounceable(h, listOf(first)).size) + for (i in 0 until 1_000) { + clock.now = t0 + i * 1_000L + log.markSeen(if (i % 2 == 0) h else other, listOf(ev("e$i", clock.now))) + } + clock.now = t0 + 5 * hour + assertEquals(emptyList(), log.claimAnnounceable(h, listOf(first)), "re-announced a still-eligible event") + assertEquals(1_001, stored(storage, clock.now).size) + } + + @Test + fun `entries age out one retention period after their anchor`() { + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + log.markSeen(h, listOf(ev("a", t0 - hour))) + log.markSeen(other, listOf(ev("o", t0 - hour))) + clock.now = t0 + SeenLog.RETENTION_MS + log.markSeen(h, listOf(ev("b", clock.now))) + assertEquals(setOf("a", "b"), stored(storage, clock.now).ids(h), "pruned at the boundary") + clock.now += 1 + log.markSeen(h, listOf(ev("c", clock.now))) + assertEquals(setOf("b", "c"), stored(storage, clock.now).ids(h)) + assertNull(stored(storage, clock.now).hosts[other], "a computer whose entries all aged out keeps an empty part") + } + + @Test + fun `a desktop clock running ahead keeps its event until the event itself is too old`() { + // Seen at t0, but the event is stamped 20 h ahead: it stays announceable until ts + 6 h, + // i.e. t0 + 26 h, past t0 + RETENTION (24 h). The anchor must carry the event's ts. + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + val ahead = ev("ahead", t0 + 20 * hour) + assertEquals(1, log.claimAnnounceable(h, listOf(ahead)).size) + clock.now = t0 + 25 * hour + log.markSeen(h, listOf(ev("other", clock.now))) + assertTrue(SeenLog.announceable(ahead, clock.now)) + assertEquals(emptyList(), log.claimAnnounceable(h, listOf(ahead))) + } + + @Test + fun `seeing an event again moves its anchor forward, never back`() { + val clock = Clock(t0) + val storage = MemStorage() + val log = SeenLog(storage, clock) + log.markSeen(h, listOf(ev("a", t0 + 10 * hour))) + clock.now = t0 + hour + log.markSeen(h, listOf(ev("a", t0 + 10 * hour))) + assertEquals(t0 + 10 * hour, stored(storage, clock.now).anchors(h)?.get("a")) + clock.now = t0 + 12 * hour + log.markSeen(h, listOf(ev("a", t0 + 10 * hour))) + assertEquals(t0 + 12 * hour, stored(storage, clock.now).anchors(h)?.get("a")) + } + + @Test + fun `an unreadable log is empty and an unreadable time counts as seen now`() { + assertEquals(0, SeenLog.decode("not json", t0).size) + assertEquals(0, SeenLog.decode("[1,2]", t0).size) + val messy = SeenLog.decode( + """{"hosts":{"h":{"a":5,"b":"x","noded":{"t":6,"n":"n9"},"badTime":{"t":"x","n":7},"noTime":{"n":"n1"},"list":[]},"bad":[1],"s":"x"},"shared":{"c":"x","d":7}}""", + t0 + ) + assertEquals(setOf("h"), messy.hosts.keys) + assertEquals?>( + mapOf( + "a" to SeenLog.Seen(5, null), + "b" to SeenLog.Seen(t0, null), + "noded" to SeenLog.Seen(6, "n9"), + "badTime" to SeenLog.Seen(t0, null), + "noTime" to SeenLog.Seen(t0, "n1"), + "list" to SeenLog.Seen(t0, null) + ), + messy.hosts["h"] + ) + assertEquals(mapOf("c" to t0, "d" to 7L), messy.shared) + assertEquals(0, SeenLog.decode("""{"hosts":"x","shared":[1]}""", t0).size) + val round = SeenLog.decode(SeenLog.encode(messy), t0) + assertEquals(messy.hosts, round.hosts) + assertEquals(messy.shared, round.shared) + assertEquals(mapOf("a" to 5L, "b" to t0), SeenLog.decodeIds("""{"a":5,"b":"x"}""", t0)) + assertEquals(emptyMap(), SeenLog.decodeIds("not json", t0)) + } + + @Test + fun `the memory backstop drops the oldest anchors across computers, not arbitrary ones`() { + val clock = Clock(t0) + val storage = MemStorage(v2 = """{"s0":${t0 - 1}}""") + val log = SeenLog(storage, clock, maxEntries = 3) + for (i in 0 until 5) { + clock.now = t0 + i * 1_000L + log.markSeen(if (i % 2 == 0) h else other, listOf(ev("e$i", clock.now))) + } + val kept = stored(storage, clock.now) + assertEquals(setOf("e2", "e4"), kept.ids(h)) + assertEquals(setOf("e3"), kept.ids(other)) + assertEquals(emptyMap(), kept.shared) + } + + @Test + fun `concurrent callers do not lose each other's writes`() { + // Deterministic: the first read waits (briefly) for a second reader. Without the lock the + // second caller reads the same state and one write overwrites the other; with it the second + // caller is still waiting for the lock, so the first times out, writes, and the second + // reads that write. + val storage = object : SeenLog.Storage { + @Volatile var raw: String? = null + val readers = CountDownLatch(2) + val reads = AtomicInteger() + override fun read(): String? { + if (reads.incrementAndGet() <= 2) { + readers.countDown() + readers.await(500, TimeUnit.MILLISECONDS) + } + return raw + } + override fun readV2(): String? = null + override fun readLegacy(): Set? = null + override fun write(encoded: String) { + raw = encoded + } + } + val log = SeenLog(storage, { t0 }) + val a = thread { log.markSeen(h, listOf(ev("a", t0))) } + val b = thread { log.markSeen(other, listOf(ev("b", t0))) } + a.join() + b.join() + val kept = SeenLog.decode(storage.raw!!, t0) + assertEquals(setOf("a"), kept.ids(h)) + assertEquals(setOf("b"), kept.ids(other)) + } + + // --- The wiring, pinned in the app's source ----------------------------------------------------- + + private val store get() = AppSourcePins.app("data/HostStore.kt") + + @Test + fun `every caller names the computer its events came from`() { + // A listing announces under the computer it listed. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(AppSourcePins.app("notify/InboxNotifier.kt"), "fun announce("), + "graph.hosts.claimLive(host.id, snapshot.status?.inbox?.events.orEmpty(), onScreen, notify)" + ) + val connections = AppSourcePins.app("conn/ConnectionManager.kt") + assertTrue(connections.contains("class HostSession(val hostId: String")) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(connections, "fun notePaneShown(nodeId: String)"), + "graph.hosts.claimLive(hostId, events, OnScreen(nodes = setOf(nodeId)), notify = false)" + ) + // A merged feed holds cards of several computers: each is marked under its own. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(AppSourcePins.ui("InboxTab.kt"), "fun InboxFeedList("), + "LaunchedEffect(feed.actionable.map { it.key })", + "feed.actionable.groupBy({ it.hostId }, { it.event })", + "graph.hosts.markSeen(hostId, events)" + ) + // Reading a finished session in its terminal. + assertTrue(AppSourcePins.ui("TerminalController.kt").contains("graph.hosts.markSeen(session.hostId, listOf(ev))")) + // Nothing records for a computer no longer paired. + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(store, "fun markSeen(hostId: String"), "if (get(hostId) == null) return", "seenLog.markSeen(hostId, events)") + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(store, "fun claimLive(hostId: String"), + "if (get(hostId) == null) return emptyList()", + "seenLog.claimLive(hostId, events, onScreen, notify)" + ) + } + + @Test + fun `forgetting a computer drops its log, and pairing it again carries it over`() { + val removeSignature = "fun remove(id: String, successor: String? = null, removeToken: () -> Unit = {})" + assertTrue(store.contains(removeSignature)) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(store.substringAfter(removeSignature), ""), + "null -> seenLog.forgetHost(id)", + "id -> Unit", + "else -> seenLog.moveHost(id, successor)" + ) + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(store, "fun upsert(host: PairedHost)"), "for (old in replaced) seenLog.moveHost(old.id, host.id)") + // Forget (the computers list) passes no successor; pairing the same computer again does. + val hosts = AppSourcePins.ui("HostsScreen.kt") + assertTrue(hosts.contains("graph.hosts.remove(host.id) { graph.secure.remove(SecureStore.relayTokenKey(host.id)) }"), "Forget no longer drops the computer's seen log") + val pair = AppSourcePins.ui("PairScreen.kt") + AppSourcePins.assertInOrder(pair, "PairedHostReplacement.retired(graph.hosts.hosts.value, host, previous)", + "graph.hosts.publishPairing(host, previous") + // The current log is v3; both older formats are read once and removed by the same edit. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(store, "private val seenLog: SeenLog = SeenLog(object : SeenLog.Storage"), + "prefs.getString(SEEN_LOG_KEY, null)", + "prefs.getString(V2_SEEN_KEY, null)", + "prefs.getStringSet(LEGACY_SEEN_KEY, null)", + "prefs.edit().putString(SEEN_LOG_KEY, encoded).remove(V2_SEEN_KEY).remove(LEGACY_SEEN_KEY).apply()" + ) + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(store, "private companion object"), + "const val SEEN_LOG_KEY = \"seenEvents.v3\"", + "const val V2_SEEN_KEY = \"seenEvents.v2\"", + "const val LEGACY_SEEN_KEY = \"seenEvents\"" + ) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SessionSearchTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SessionSearchTest.kt new file mode 100644 index 0000000000..ac53ce6092 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SessionSearchTest.kt @@ -0,0 +1,182 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.AgentNodeStatus +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.AgentStatusFile +import dev.nodeterm.protocol.model.NodeInfo +import dev.nodeterm.protocol.model.NodeKind +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.SessionBucket +import dev.nodeterm.protocol.model.SessionSearch +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SessionSearchTest { + private fun node(id: String, title: String = id, agent: String? = null, cwd: String? = null, kind: NodeKind = NodeKind.TERMINAL) = + NodeInfo(id, kind, title, null, agent, null, cwd, null, null, null, null) + + private fun project(id: String, vararg nodes: NodeInfo, name: String = id, cwd: String? = null, closed: Boolean = false) = + ProjectInfo(id, name, null, cwd, null, closed, nodes.toList(), null) + + private fun status(name: String? = null, agent: String? = null, state: AgentState? = null, hibernated: Boolean = false, updatedAt: Long = 1) = + AgentNodeStatus(state, agent, null, name, hibernated, updatedAt, null) + + private fun snapshot(vararg projects: ProjectInfo, states: Map = emptyMap(), fetchedAt: Long = 1) = + ProjectsSnapshot(projects.toList(), setOf("nt-running"), AgentStatusFile(1, states, null, null, null, null), fetchedAt) + + private fun ids(snapshot: ProjectsSnapshot, query: String) = + SessionSearch.projects(snapshot, query).flatMap { it.sessions }.map { it.id } + + @Test + fun `blank queries retain original open groups and rows without including closed or nonterminal projects`() { + val a = project("a", node("second"), node("first"), node("note", kind = NodeKind.STICKY)) + val b = project("b", node("third")) + val s = snapshot(a, project("closed", node("hidden"), closed = true), project("notes", node("note", kind = NodeKind.STICKY)), b) + for (query in listOf("", " ", "\t\n")) { + val found = SessionSearch.projects(s, query) + assertEquals(listOf("a", "b"), found.map { it.id }) + assertEquals(listOf("second", "first", "third"), ids(s, query)) + assertSame(a, found[0]) + assertSame(b, found[1]) + } + } + + @Test + fun `the current displayed session name takes precedence over a hidden canvas title`() { + val n = node("n", title = "Old canvas name", agent = "claude") + val s = snapshot(project("p", n), states = mapOf("n" to status(name = "Current task"))) + assertEquals("Current task", SessionSearch.title(n, s)) + assertEquals(listOf("n"), ids(s, " CURRENT TASK ")) + assertTrue(ids(s, "Old canvas name").isEmpty()) + val blankName = s.copy(status = s.status!!.copy(nodes = mapOf("n" to status(name = " ")))) + assertEquals("Old canvas name", SessionSearch.title(n, blankName)) + assertEquals(listOf("n"), ids(blankName, "CANVAS")) + } + + @Test + fun `blank titles fall back to the displayed agent then Terminal`() { + val agent = node("agent", title = " ", agent = "claude") + val shell = node("shell", title = "") + val s = snapshot(project("p", agent, shell)) + assertEquals("Claude Code", SessionSearch.title(agent, s)) + assertEquals("Terminal", SessionSearch.title(shell, s)) + assertEquals(listOf("agent"), ids(s, "claude code")) + assertEquals(listOf("shell"), ids(s, "terminal")) + } + + @Test + fun `project names and folders keep all sessions in that group but not empty or closed groups`() { + val a = project("a", node("one"), node("two"), name = "Hike project", cwd = "/work/hike folder") + val s = snapshot(a, project("b", node("three")), project("closed", node("hidden"), name = "Hike", closed = true), project("empty", name = "Hike")) + for (query in listOf("HIKE PROJECT", " /WORK/HIKE FOLDER ")) { + val found = SessionSearch.projects(s, query) + assertEquals(listOf("a"), found.map { it.id }) + assertEquals(listOf("one", "two"), ids(s, query)) + assertSame(a, found.single()) + } + } + + @Test + fun `agent detail matches even when a custom session title hides the agent and the hook supplies its id`() { + val n = node("n", title = "Fix tests") + val s = snapshot(project("p", n), states = mapOf("n" to status(agent = "copilot"))) + assertEquals("Fix tests", SessionSearch.title(n, s)) + assertEquals("GitHub Copilot", SessionSearch.agentLabel(n, s)) + assertEquals(listOf("n"), ids(s, "github copilot")) + val known = node("known", title = "Different task", agent = "codex") + val preferred = snapshot(project("p", known), states = mapOf("known" to status(agent = "claude"))) + assertEquals("Codex", SessionSearch.agentLabel(known, preferred)) + assertTrue(ids(preferred, "Claude Code").isEmpty(), "match the displayed agent, not a hidden hook id") + } + + @Test + fun `phone shell folders distinguish sessions under the same project without changing their ids`() { + val a = node("phone-a", title = "Terminal", cwd = "/work/one folder") + val b = node("phone-b", title = "Terminal", cwd = "/work/two folder") + val p = project("phone-terminals", a, b) + val s = snapshot(p) + val found = SessionSearch.projects(s, "TWO FOLDER") + assertEquals(listOf("phone-terminals"), found.map { it.id }) + assertEquals(listOf("phone-b"), found.single().sessions.map { it.id }) + assertSame(b, found.single().sessions.single()) + assertEquals(listOf(a, b), p.nodes, "search never rewrites the received listing") + } + + @Test + fun `queries are trimmed case insensitive Unicode literals rather than regular expressions or joined fields`() { + val s = snapshot(project("Beta", node("literal", title = "Task [a-z].* ΩMEGA"), node("other", title = "Alpha"))) + assertEquals(listOf("literal"), ids(s, " [A-Z].* ")) + assertEquals(listOf("literal"), ids(s, "ωmega")) + assertTrue(ids(s, "^Task").isEmpty()) + assertTrue(ids(s, "AlphaBeta").isEmpty(), "do not invent matches spanning independent fields") + } + + @Test + fun `filtering preserves project and node order and the status and live data used for bucket order`() { + val a = project("a", node("sleep", title = "same"), node("running", title = "same"), node("excluded", title = "other")) + val b = project("b", node("needs", title = "same"), node("unknown", title = "same")) + val s = snapshot(a, b, states = mapOf( + "sleep" to status(hibernated = true, updatedAt = 9), + "running" to status(state = AgentState.WORKING, updatedAt = 2), + "needs" to status(state = AgentState.WAITING, updatedAt = 7) + )) + assertEquals(listOf("a", "b"), SessionSearch.projects(s, "same").map { it.id }) + assertEquals(listOf("sleep", "running", "needs", "unknown"), ids(s, "same")) + assertEquals(SessionBucket.SLEEPING, s.statusOf("sleep")?.bucket) + assertEquals(SessionBucket.RUNNING, s.statusOf("running")?.bucket) + assertEquals(SessionBucket.NEEDS_YOU, s.statusOf("needs")?.bucket) + assertTrue(s.isLive("running")) + assertSame(a.nodes[0], SessionSearch.projects(s, "same")[0].sessions[0]) + } + + @Test + fun `a relisting uses current titles and removals instead of holding stale search results`() { + val n = node("n", title = "Original") + val first = snapshot(project("p", n)) + assertEquals(listOf("n"), ids(first, "Original")) + val renamed = snapshot(project("p", n), states = mapOf("n" to status(name = "Renamed"))) + assertTrue(ids(renamed, "Original").isEmpty()) + assertEquals(listOf("n"), ids(renamed, "Renamed")) + assertTrue(ids(snapshot(), "Renamed").isEmpty()) + } + + @Test + fun `search misses are distinct from loading and a completed truly empty listing`() { + val s = snapshot(project("p", node("one"))) + assertTrue(SessionSearch.projects(s, "missing").isEmpty()) + assertEquals(SessionSearch.EmptyState.NO_MATCHES, SessionSearch.emptyState(s, "missing")) + assertEquals(SessionSearch.EmptyState.LOADING, SessionSearch.emptyState(ProjectsSnapshot.EMPTY, "missing")) + assertEquals(SessionSearch.EmptyState.EMPTY, SessionSearch.emptyState(snapshot(), "missing")) + val closed = snapshot(project("p", node("one"), closed = true)) + assertEquals(SessionSearch.EmptyState.EMPTY, SessionSearch.emptyState(closed, "one")) + assertEquals(SessionSearch.EmptyState.LOADING, SessionSearch.emptyState(ProjectsSnapshot.EMPTY, " ")) + } + + @Test + fun `the native query is saved within existing host and tab entry holders and clearing updates it`() { + val ui = AppSourcePins.ui("SessionsTab.kt") + assertTrue(ui.contains("var query by rememberSaveable(hostId) { mutableStateOf(\"\") }")) + AppSourcePins.assertInOrder(ui, "value = query", "onValueChange = { query = it }", "label = { Text(\"Search sessions\") }") + assertTrue(ui.contains("TextButton(onClick = { query = \"\" }) { Text(\"Clear search\") }"), "clear must be a labelled native action") + assertTrue(ui.contains("val projects = SessionSearch.projects(snapshot, query)"), "draw the current filtered snapshot") + assertTrue(AppSourcePins.ui("HostScreen.kt").contains("tabStates.SaveableStateProvider(tab)")) + assertTrue(AppSourcePins.app("MainActivity.kt").contains("saved.SaveableStateProvider(top.key)")) + } + + @Test + fun `native search keeps project status grouping notes and separate empty labels`() { + val ui = AppSourcePins.blockAfter(AppSourcePins.ui("SessionsTab.kt"), "fun SessionsTab(") + assertTrue(ui.contains("when (SessionSearch.emptyState(snapshot, query))")) + for (label in listOf("Loading sessions…", "No sessions on this computer yet.", "No sessions match your search.")) assertTrue(ui.contains(label)) + assertTrue(ui.contains("newSessionNote?.let { NewSessionNote(it) }"), "unavailable New-session reason survives an empty search") + assertTrue(ui.contains("if (newSessionNote != null) item(key = \"new-session-note\")")) + AppSourcePins.assertInOrder(ui, "for (project in projects)", "project.sessions.groupBy", "for (bucket in BUCKET_ORDER)", "sortedByDescending", "items(rows") + assertFalse(ui.contains("if (projects.isEmpty()) return")) + assertTrue(AppSourcePins.ui("SessionsTab.kt").contains("SessionSearch.title(node, snapshot)"), "search and the displayed title share the same fallback") + assertTrue(AppSourcePins.ui("SessionsTab.kt").contains("add(SessionSearch.agentLabel(node, snapshot))"), "search and the displayed agent detail agree") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt new file mode 100644 index 0000000000..fbb16e4ac9 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SettingsLeaveTest.kt @@ -0,0 +1,177 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.relay.ApiBaseSetting +import dev.nodeterm.protocol.relay.ApiBaseSetting.OnLeave +import dev.nodeterm.protocol.relay.RelayApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A44: leaving Settings by the system back (gesture or button) threw away the edited device name + * and relay address; only the top-bar arrow stored them. And an address that was not https was + * dropped by that arrow without a word. + * + * The address rule and the leave decision are pure and tested here. The wiring (both backs run the + * one `leave()`, which stores before it pops; a screen taken away without a back stores silently; and + * nothing stores per keystroke) cannot run on a JVM, so it is pinned in the app's source + * ([AppSourcePins]); whether the gesture reaches it on a device has not been checked. + */ +class SettingsLeaveTest { + @Test + fun `an https address is stored as typed, trimmed and without a trailing slash`() { + assertEquals(RelayApi.DEFAULT_API_BASE, ApiBaseSetting.accept(RelayApi.DEFAULT_API_BASE)) + assertEquals("https://relay.example.com", ApiBaseSetting.accept(" https://relay.example.com/ ")) + assertEquals("https://relay.example.com:8443/api", ApiBaseSetting.accept("https://relay.example.com:8443/api/")) + // The scheme is case-insensitive, as it is to OkHttp (a keyboard may capitalize the first letter). + assertEquals("Https://relay.example.com", ApiBaseSetting.accept("Https://relay.example.com")) + } + + @Test + fun `anything that is not an https address is refused`() { + assertNull(ApiBaseSetting.accept("")) + assertNull(ApiBaseSetting.accept("http://relay.example.com")) + assertNull(ApiBaseSetting.accept("relay.example.com")) + assertNull(ApiBaseSetting.accept("wss://relay.example.com")) + assertNull(ApiBaseSetting.accept("https:relay.example.com")) + } + + @Test + fun `an https prefix without a usable URL behind it is refused`() { + // All passed the old startsWith("https://") check. "https://" was stored as "https:", which + // OkHttp read as a host named "v1"; with the next three every relay call threw + // IllegalArgumentException from Request.Builder.url instead of a RelayApiException. + assertNull(ApiBaseSetting.accept("https://")) + assertNull(ApiBaseSetting.accept("https://?x")) + assertNull(ApiBaseSetting.accept("https://:443")) + assertNull(ApiBaseSetting.accept("https://relay example.com")) + // The /v1/... path is appended to the address: a query or fragment would swallow it. + assertNull(ApiBaseSetting.accept("https://relay.example.com?x=1")) + assertNull(ApiBaseSetting.accept("https://relay.example.com#top")) + } + + @Test + fun `leaving stores an edited address and reports a refused one`() { + val stored = RelayApi.DEFAULT_API_BASE + assertEquals(OnLeave.Save("https://relay.example.com"), ApiBaseSetting.onLeave("https://relay.example.com/", stored)) + assertEquals(OnLeave.Save("https://other.example.com"), ApiBaseSetting.onLeave("https://other.example.com", "https://relay.example.com")) + assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("http://relay.example.com", stored)) + assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("", stored)) + assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("https://", stored)) + } + + @Test + fun `leaving without an edit stores nothing`() { + // The first A44 fix stored the unedited address on every leave. Only the arrow used to, and + // the system back (what most users press) stored nothing. + val custom = "https://relay.example.com" + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave(custom, custom)) + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave(" $custom/ ", custom)) + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave("https://relay.example.com:8443/api", "https://relay.example.com:8443/api/")) + } + + @Test + fun `the built-in default is never stored as an address`() { + // A phone that stores no address reads the default, and follows a later build's default. The + // first A44 fix stored it on every leave, pinning the phone to this build's default for good. + // The unedited default: forgetting an address the phone does not have writes nothing, and + // it unpins a phone an earlier build did store the default on (Keep would leave it pinned). + val default = RelayApi.DEFAULT_API_BASE + assertEquals(OnLeave.UseDefault, ApiBaseSetting.onLeave(default, default)) + // "Reset to default" over a custom address forgets it rather than storing the default. + assertEquals(OnLeave.UseDefault, ApiBaseSetting.onLeave(default, "https://relay.example.com")) + assertEquals(OnLeave.UseDefault, ApiBaseSetting.onLeave(" $default/ ", "https://relay.example.com")) + } + + @Test + fun `a refused address that is already the stored one is left alone without a message`() { + // Only an older build could have stored it (its check let "https://" through, and HostStore + // stored that as "https:"); the user did not type it here. + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave("https:", "https:")) + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave(" https:// ", "https:")) + assertEquals(OnLeave.Rejected, ApiBaseSetting.onLeave("http://relay.example.com", "https:")) + } + + @Test + fun `the field flags what it holds now, including an unusable address an older build stored`() { + val default = RelayApi.DEFAULT_API_BASE + assertNull(ApiBaseSetting.fieldError(default, default)) + assertNull(ApiBaseSetting.fieldError("https://relay.example.com", default)) + // Typed here: it will not be saved (leaving says so too). + assertEquals(ApiBaseSetting.NOT_SAVED, ApiBaseSetting.fieldError("http://relay.example.com", default)) + assertEquals(ApiBaseSetting.NOT_SAVED, ApiBaseSetting.fieldError("http://relay.example.com", "https:")) + // Stored by an older build and not edited: leaving keeps it without a message, but the field + // flags it (the review of A44), since every relay call uses it. It used to show no error at all. + assertEquals(ApiBaseSetting.STORED_UNUSABLE, ApiBaseSetting.fieldError("https:", "https:")) + assertEquals(ApiBaseSetting.STORED_UNUSABLE, ApiBaseSetting.fieldError(" https:// ", "https:")) + assertEquals(OnLeave.Keep, ApiBaseSetting.onLeave("https:", "https:")) + } + + private val settings get() = AppSourcePins.ui("SettingsScreen.kt") + + @Test + fun `the system back runs the same leave as the top-bar arrow`() { + val src = settings + assertTrue(src.contains("import androidx.activity.compose.BackHandler"), "SettingsScreen registers no BackHandler") + // Enabled like AppContent's, so it never swallows the back that should close the app. + assertEquals("{ leave() }", AppSourcePins.blockAfter(src, "BackHandler(enabled = nav.size > 1)")) + val arrow = AppSourcePins.blockAfter(src, "navigationIcon =") + AppSourcePins.assertInOrder(arrow, "IconButton(onClick = { leave() })") + assertFalse(arrow.contains("nav.pop()"), "the arrow pops without the shared leave():\n$arrow") + } + + @Test + fun `leave stores the name and the address, tells about a refused one, then pops`() { + val save = AppSourcePins.blockAfter(settings, "fun save(onRejected: () -> Unit)") + AppSourcePins.assertInOrder( + save, + // An unedited name is not stored: a phone that stores no name is named after its model. + "if (name.trim() != graph.hosts.deviceName) graph.hosts.deviceName = name", + "ApiBaseSetting.onLeave(apiBase, graph.hosts.apiBase)", + "OnLeave.Save -> graph.hosts.apiBase = edit.value", + "OnLeave.UseDefault -> graph.hosts.useDefaultApiBase()", + "OnLeave.Keep -> {}", + "OnLeave.Rejected -> onRejected()" + ) + val leave = AppSourcePins.blockAfter(settings, "fun leave()") + AppSourcePins.assertInOrder(leave, "save(onRejected = {", "Toast.makeText(", ".show()", "nav.pop()") + } + + @Test + fun `leaving the screen without a back stores the edits too, without a message`() { + // A notification tap (replaceAll) or a pairing link (push) takes Settings off the screen + // without running leave(), and the edits used to be lost silently (the review of A44). + val effect = AppSourcePins.blockAfter(settings, "DisposableEffect(Unit)") + assertEquals("{ save(onRejected = {}) }", AppSourcePins.blockAfter(effect, "onDispose")) + assertFalse(effect.contains("Toast"), "the dispose save shows a message nobody is there to read:\n$effect") + } + + @Test + fun `using the default forgets the stored address, so the getter falls back to the default`() { + val store = AppSourcePins.app("data/HostStore.kt") + val forget = AppSourcePins.blockAfter(store, "fun useDefaultApiBase()") + assertEquals("""prefs.edit().remove("apiBase").apply()""", forget.removeSurrounding("{", "}").trim()) + assertTrue( + store.contains("""get() = prefs.getString("apiBase", null) ?: dev.nodeterm.protocol.relay.RelayApi.DEFAULT_API_BASE"""), + "HostStore.apiBase no longer answers the built-in default while no address is stored" + ) + } + + @Test + fun `nothing else pops or stores the edits`() { + val src = settings + // One pop (in leave), and the two stores only in save: never per keystroke, never skipped. + assertEquals(1, Regex("""nav\.pop\(\)""").findAll(src).count()) + assertEquals(1, Regex("""graph\.hosts\.apiBase\s*=(?!=)""").findAll(src).count()) + assertEquals(1, Regex("""graph\.hosts\.deviceName\s*=(?!=)""").findAll(src).count()) + assertEquals(1, Regex("""graph\.hosts\.useDefaultApiBase\(\)""").findAll(src).count()) + // save() runs from leave() and when the screen is disposed, nowhere else. + assertEquals(2, Regex("""\bsave\(onRejected = """).findAll(src).count()) + // The field warns by what it holds now (ApiBaseSetting.fieldError), not only about an edit. + assertTrue(src.contains("val apiBaseError = ApiBaseSetting.fieldError(apiBase, graph.hosts.apiBase)")) + assertTrue(src.contains("isError = apiBaseError != null")) + assertTrue(src.contains("supportingText = apiBaseError?.let { error -> { Text(error) } }")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SourceControlTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SourceControlTest.kt new file mode 100644 index 0000000000..fa19c8f6f9 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SourceControlTest.kt @@ -0,0 +1,556 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.git.GitConflict +import dev.nodeterm.protocol.git.GitDiff +import dev.nodeterm.protocol.git.GitDiff.Kind +import dev.nodeterm.protocol.git.GitFileChange +import dev.nodeterm.protocol.git.GitReplies +import dev.nodeterm.protocol.git.GitResult +import dev.nodeterm.protocol.git.GitStatus +import dev.nodeterm.protocol.git.SourceControl +import dev.nodeterm.protocol.git.SourceControlGate +import dev.nodeterm.protocol.git.SourceControlGate.Availability +import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.GIT_WRITE_TIMEOUT_MS +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostCapabilities +import dev.nodeterm.protocol.host.HostConnection +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.HostUnansweredException +import dev.nodeterm.protocol.host.LabelEditResult +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.NewSessionHint +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.TransportKind +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.KanbanColumn +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.relay.RelaySocket +import dev.nodeterm.protocol.relay.RpcException +import dev.nodeterm.protocol.relay.RpcUnansweredException +import dev.nodeterm.protocol.host.hostException +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A29: the phone's Source Control over the desktop's typed, jailed git bridge. What the bridge + * really answers is pinned by `RelayInteropTest` against the desktop's own `GitService`; this pins + * how the phone reads it (including what it does with a shape it does not know), what it sends, when + * it refuses before sending anything, and where the app's screen gets its connection. + */ +class SourceControlTest { + // ---- reading the replies ---------------------------------------------------------------------- + + /** `GitStatus` (src/shared/types.ts), every field the desktop sends. */ + private val statusJson = """ + {"hasRepo":true,"repoName":"me/app","branch":"main","branches":["main","dev"],"remoteBranches":["origin/main"], + "ahead":2,"behind":1,"hasRemote":true,"hasOrigin":true,"hasUpstream":true,"ghAvailable":false,"ghAuthed":false, + "staged":[{"path":"src/a.ts","status":"M","added":3,"deleted":1}], + "changes":[{"path":"src/a.ts","status":"M","added":1,"deleted":0}, + {"path":"docs/new.md","status":"U","added":0,"deleted":0}, + {"path":"old.txt","status":"D","added":0,"deleted":7}]} + """ + + @Test + fun `the status reads the desktop's shape and splits untracked files out of the changes`() { + val st = GitReplies.status(Json.parseToJsonElement(statusJson))!! + assertTrue(st.hasRepo) + assertEquals("me/app", st.repoName) + assertEquals("main", st.branch) + assertEquals(2, st.ahead) + assertEquals(1, st.behind) + assertTrue(st.hasRemote && st.hasUpstream) + assertEquals(listOf(GitFileChange("src/a.ts", "M", 3, 1)), st.staged) + assertEquals(listOf("src/a.ts", "old.txt"), st.unstaged.map { it.path }) + assertEquals(listOf("docs/new.md"), st.untracked.map { it.path }) + assertTrue(st.untracked.single().untracked) + } + + @Test + fun `a folder that is not a repository is an answer, a reply of another shape is not`() { + // What GitService.status answers for a plain folder. + val plain = GitReplies.status( + Json.parseToJsonElement("""{"hasRepo":false,"repoName":"demo","branch":"","branches":[],"ahead":0,"behind":0, + "hasRemote":false,"hasOrigin":false,"hasUpstream":false,"ghAvailable":false,"ghAuthed":false,"staged":[],"changes":[]}""") + )!! + assertFalse(plain.hasRepo) + + assertNull(GitReplies.status(null)) + assertNull(GitReplies.status(JsonPrimitive("nope"))) + assertNull(GitReplies.status(Json.parseToJsonElement("""{"branch":"main"}""")), "no hasRepo: not a status") + assertNull(GitReplies.result(Json.parseToJsonElement("""{"message":"x"}""")), "no ok: not a result") + assertNull(GitReplies.history(Json.parseToJsonElement("""{"hasMore":false}""")), "no items: not a history") + assertNull(GitReplies.diff(Json.parseToJsonElement("""{}""")), "a diff is the text itself") + assertEquals("", GitReplies.diff(JsonPrimitive(""))) + } + + @Test + fun `wrong-typed fields degrade to absent instead of failing the whole status`() { + val st = GitReplies.status( + Json.parseToJsonElement("""{"hasRepo":true,"branch":7,"ahead":"2","behind":-3, + "staged":[{"path":"a","status":"A","added":"x"},{"status":"M"},{"path":""},5],"changes":"no"}""") + )!! + assertEquals("", st.branch) + assertEquals(0, st.ahead) + assertEquals(0, st.behind) + assertEquals(listOf(GitFileChange("a", "A", 0, 0)), st.staged, "entries with no path are dropped") + assertTrue(st.changes.isEmpty()) + } + + /** + * Every unmerged state, as the desktop's `GitService.status` turns `git status --porcelain -uall` + * into its two lists (src/core/git-service.ts: `??` → a `U` in `changes` only; otherwise `X` → a + * `staged` entry and `Y` → a `changes` entry, each when not blank). The porcelain is what git 2.43 + * printed for a merge that conflicted every way (renames give `AU`/`UA` and `DD`), plus an + * ordinary staged-and-changed file (`MM`) and an intent-to-add one (` A`), neither of them unmerged. + */ + private val conflictedStatusJson = """ + {"hasRepo":true,"repoName":"app","branch":"main","ahead":0,"behind":0,"hasRemote":false,"hasUpstream":false, + "staged":[{"path":"aa.txt","status":"A","added":0,"deleted":0}, + {"path":"both.txt","status":"M","added":1,"deleted":0}, + {"path":"dd-ours.txt","status":"A","added":0,"deleted":0}, + {"path":"dd-theirs.txt","status":"U","added":0,"deleted":0}, + {"path":"dd.txt","status":"D","added":0,"deleted":0}, + {"path":"du.txt","status":"D","added":0,"deleted":0}, + {"path":"ua.txt","status":"A","added":1,"deleted":0}, + {"path":"ud.txt","status":"U","added":0,"deleted":0}, + {"path":"uu.txt","status":"U","added":0,"deleted":0}], + "changes":[{"path":"aa.txt","status":"A","added":4,"deleted":0}, + {"path":"both.txt","status":"M","added":1,"deleted":0}, + {"path":"dd-ours.txt","status":"U","added":0,"deleted":0}, + {"path":"dd-theirs.txt","status":"A","added":0,"deleted":0}, + {"path":"dd.txt","status":"D","added":0,"deleted":0}, + {"path":"du.txt","status":"U","added":0,"deleted":0}, + {"path":"ita.txt","status":"A","added":0,"deleted":0}, + {"path":"ud.txt","status":"D","added":0,"deleted":0}, + {"path":"uu.txt","status":"U","added":4,"deleted":0}, + {"path":"fresh.txt","status":"U","added":0,"deleted":0}]} + """ + + @Test + fun `an unmerged path is a conflict, never an untracked file, a staged change or a working-tree one`() { + val st = GitReplies.status(Json.parseToJsonElement(conflictedStatusJson))!! + assertEquals( + listOf( + "aa.txt" to "AA", "dd-ours.txt" to "AU", "dd-theirs.txt" to "UA", "dd.txt" to "DD", + "du.txt" to "DU", "ud.txt" to "UD", "uu.txt" to "UU" + ), + st.conflicts.map { it.path to it.code } + ) + assertEquals( + listOf("both added", "added by us", "added by them", "both deleted", "deleted by us", "deleted by them", "both modified"), + st.conflicts.map { it.description } + ) + // What a commit would take, and what is left to stage: no unmerged path in either. + assertEquals(listOf("both.txt", "ua.txt"), st.staged.map { it.path }) + assertEquals(listOf("both.txt", "ita.txt"), st.unstaged.map { it.path }) + // `U` with the path in `changes` alone is the only untracked file. + assertEquals(listOf("fresh.txt"), st.untracked.map { it.path }) + assertFalse(st.clean) + assertEquals("Resolve the conflicts on the computer first.", SourceControl.commitBlocker(st, "Merge")) + + // A `U` the desktop could not have sent alone on the index side is still unmerged. + assertEquals(listOf(GitConflict("x", "U", "")), GitReplies.status( + Json.parseToJsonElement("""{"hasRepo":true,"staged":[{"path":"x","status":"U"}],"changes":[]}""") + )!!.conflicts) + for ((x, y) in listOf("M" to "M", "A" to "M", "A" to "D", "R" to "M", "D" to "", "M" to "D")) { + assertFalse(GitConflict.isUnmerged(x, y), "$x$y is not unmerged") + } + for (xy in listOf("DD", "AU", "UD", "UA", "DU", "AA", "UU")) { + assertTrue(GitConflict.isUnmerged(xy.take(1), xy.drop(1)), "$xy is unmerged") + } + } + + @Test + fun `history reads the commits, the short hash falling back to the id`() { + val h = GitReplies.history( + Json.parseToJsonElement( + """{"items":[ + {"id":"0123456789abcdef","parentIds":["aa"],"subject":"Fix it","message":"Fix it\n\nbody","displayId":"0123456", + "author":"Ann","authorEmail":"ann@x","timestamp":1700000000000, + "references":[{"id":"refs/heads/main","name":"main","category":"branches"},{"id":"refs/remotes/origin/main","name":"origin/main"}]}, + {"id":"fedcba9876543210","subject":"Older"}, + {"subject":"no id"}], + "currentRef":{"id":"refs/heads/main","name":"main"},"remoteRef":{"id":"refs/remotes/origin/main","name":"origin/main"}, + "hasIncomingChanges":false,"hasOutgoingChanges":true,"hasMore":true,"limit":50}""" + ) + )!! + assertEquals(listOf("Fix it", "Older"), h.commits.map { it.subject }) + val head = h.commits.first() + assertEquals("0123456", head.shortId) + assertEquals("Ann", head.author) + assertEquals(1_700_000_000_000, head.timestampMs) + assertEquals(listOf("main", "origin/main"), head.refs) + assertEquals("fedcba9", h.commits[1].shortId) + assertNull(h.commits[1].author) + assertTrue(h.hasMore && h.hasOutgoingChanges && !h.hasIncomingChanges) + assertEquals("main", h.currentRef) + assertEquals("origin/main", h.remoteRef) + } + + // ---- the diff ----------------------------------------------------------------------------------- + + @Test + fun `file headers are told apart from added and removed lines that start with the same characters`() { + val text = """ + diff --git a/x.txt b/x.txt + index 1111111..2222222 100644 + --- a/x.txt + +++ b/x.txt + @@ -1,3 +1,3 @@ fun a() + keep + --- removed line that starts with two dashes + +++ added line that starts with two pluses + -old + +new + \ No newline at end of file + diff --git a/y.bin b/y.bin + Binary files a/y.bin and b/y.bin differ + """.trimIndent() + "\n" + val d = GitDiff.parse(text) + assertEquals(0, d.omitted) + assertEquals( + listOf( + Kind.META, Kind.META, Kind.META, Kind.META, Kind.HUNK, Kind.CONTEXT, + Kind.DEL, Kind.ADD, Kind.DEL, Kind.ADD, Kind.NOTE, Kind.META, Kind.META + ), + d.lines.map { it.kind } + ) + assertEquals("+++ added line that starts with two pluses", d.lines[7].text) + } + + @Test + fun `an untracked file's diff and a CRLF diff read the same way`() { + // What `git diff --no-index -- /dev/null new.txt` prints, with Windows line endings. + val text = "diff --git a/new.txt b/new.txt\r\nnew file mode 100644\r\n--- /dev/null\r\n+++ b/new.txt\r\n@@ -0,0 +1 @@\r\n+new file\r\n" + val d = GitDiff.parse(text) + assertEquals(listOf(Kind.META, Kind.META, Kind.META, Kind.META, Kind.HUNK, Kind.ADD), d.lines.map { it.kind }) + assertEquals("+new file", d.lines.last().text) + assertTrue(GitDiff.parse("").isEmpty) + } + + @Test + fun `a conflicted file's combined diff reads its marker columns`() { + // `git diff -- uu.txt` after a merge that conflicted (git 2.43): every line of the result is + // new against one side or both, which is how git colours it; ` +ours` is not context. + val conflict = """ + diff --cc uu.txt + index b19a1e9,950b81b..0000000 + --- a/uu.txt + +++ b/uu.txt + @@@ -1,1 -1,1 +1,5 @@@ + ++<<<<<<< HEAD + +ours + ++======= + + theirs + ++>>>>>>> theirs + """.trimIndent() + "\n" + val d = GitDiff.parse(conflict) + assertEquals(listOf(Kind.META, Kind.META, Kind.META, Kind.META, Kind.HUNK) + List(5) { Kind.ADD }, d.lines.map { it.kind }) + + // Two columns: a `-` in either is a line the result dropped, two blanks are context. A plain + // diff after it is back to one column. + val mixed = "diff --cc f\n@@@ -1,3 -1,3 +1,2 @@@\n same\n- ours-old\n -theirs-old\n+-x\n" + + "diff --git a/g b/g\n@@ -1 +1 @@\n -kept\n" + assertEquals( + listOf(Kind.META, Kind.HUNK, Kind.CONTEXT, Kind.DEL, Kind.DEL, Kind.DEL, Kind.META, Kind.HUNK, Kind.CONTEXT), + GitDiff.parse(mixed).lines.map { it.kind } + ) + // What git prints for an unmerged path one side deleted is a note, not a hunk. + assertEquals(listOf(Kind.META), GitDiff.parse("* Unmerged path du.txt\n").lines.map { it.kind }) + } + + @Test + fun `a huge diff is cut to a bounded view that says how much it left out`() { + val text = "@@ -1 +1 @@\n" + (1..10).joinToString("\n") { "+line $it" } + "\n" + val d = GitDiff.parse(text, maxLines = 4) + assertEquals(4, d.lines.size) + assertEquals(7, d.omitted) + + val long = GitDiff.parse("@@ -1 +1 @@\n+" + "x".repeat(GitDiff.MAX_LINE_CHARS * 2)) + assertEquals(GitDiff.MAX_LINE_CHARS + 2, long.lines[1].text.length, "cut to the cap plus \" …\"") + assertTrue(long.lines[1].text.endsWith(" …")) + } + + // ---- what the screen allows --------------------------------------------------------------------- + + private fun status(staged: Int = 0, hasRemote: Boolean = true, hasRepo: Boolean = true) = GitStatus( + hasRepo = hasRepo, repoName = "", branch = "main", ahead = 0, behind = 0, hasRemote = hasRemote, hasUpstream = hasRemote, + staged = List(staged) { GitFileChange("f$it", "M", 1, 0) }, changes = emptyList() + ) + + @Test + fun `a commit needs something staged and a message, which is what the desktop commits`() { + assertEquals("Stage the changes to commit first.", SourceControl.commitBlocker(status(staged = 0), "msg")) + assertEquals("Write a commit message.", SourceControl.commitBlocker(status(staged = 1), " ")) + assertNull(SourceControl.commitBlocker(status(staged = 1), "msg")) + assertEquals("There is no repository to commit to.", SourceControl.commitBlocker(status(hasRepo = false), "msg")) + assertEquals("There is no repository to commit to.", SourceControl.commitBlocker(null, "msg")) + } + + @Test + fun `push and pull need a remote`() { + assertNull(SourceControl.syncBlocker(status())) + assertEquals("This repository has no remote to push to or pull from.", SourceControl.syncBlocker(status(hasRemote = false))) + } + + @Test + fun `what a write verb answered is shown as the computer said it`() { + val stderr = "fatal: pathspec 'x' did not match any files" + assertEquals(SourceControl.Outcome(error = true, text = stderr), SourceControl.outcome(GitResult(false, stderr))) + assertEquals(SourceControl.Outcome(error = false, text = "Pushed."), SourceControl.outcome(GitResult(true, "Pushed."))) + assertNull(SourceControl.outcome(GitResult(true, "")), "staging says nothing on success") + assertTrue(SourceControl.outcome(GitResult(false, ""))!!.error) + } + + // ---- whether it can open at all ----------------------------------------------------------------- + + private fun project(cwd: String? = "/work/app", ssh: String? = null) = + ProjectInfo(id = "p1", name = "App", color = null, cwd = cwd, sshTarget = ssh, closed = false, nodes = emptyList(), board = null) + + @Test + fun `the gate opens on the project's folder through either leg`() { + assertEquals(Availability.Available("/work/app"), SourceControlGate.of(project(), LegRouting.Leg.Primary)) + assertEquals(Availability.Available("/work/app"), SourceControlGate.of(project(), LegRouting.Leg.Relay)) + // A Windows computer's folder goes as it is: the desktop's jail resolves it, not the phone. + assertEquals(Availability.Available("C:\\work\\app"), SourceControlGate.of(project("C:\\work\\app"), LegRouting.Leg.Primary)) + } + + @Test + fun `the gate says why it cannot open, and never sends a request it knows will be refused`() { + val noLeg = LegRouting.route( + dev.nodeterm.protocol.host.Capability.GIT, TransportKind.SSH, + HostCapabilities(boardWrites = false, git = false, nodeActions = false, registerNode = false, answerApprovals = true), + LegRouting.RelayLeg.NOT_SET_UP + ) + val reason = assertIs(SourceControlGate.of(project(), noLeg)).reason + assertEquals((noLeg as LegRouting.Leg.Unavailable).reason, reason) + assertTrue(reason.startsWith("Source control"), reason) + + val ssh = assertIs(SourceControlGate.of(project(cwd = null, ssh = "me@box"), LegRouting.Leg.Primary)) + assertTrue(ssh.reason.contains("me@box"), ssh.reason) + val inline = assertIs(SourceControlGate.of(project(cwd = null), LegRouting.Leg.Primary)) + assertTrue(inline.reason.contains("no folder"), inline.reason) + assertIs(SourceControlGate.of(project(cwd = " "), LegRouting.Leg.Primary)) + assertIs(SourceControlGate.of(null, LegRouting.Leg.Primary)) + + // Direct SSH operates on this computer's listed driven folder. Its relay still cannot own that project. + assertEquals(Availability.Available("/work/app"), SourceControlGate.of(project().copy(drivenRemotely = true), LegRouting.Leg.Primary)) + val driven = assertIs( + SourceControlGate.of(project().copy(drivenRemotely = true), LegRouting.Leg.Relay) + ) + assertTrue(driven.reason.contains("another computer"), driven.reason) + } + + // ---- what goes on the wire ---------------------------------------------------------------------- + + private class RecordingConn(private val answer: (GitVerb) -> JsonElement?) : HostConnection { + val calls = mutableListOf>>() + override val kind = TransportKind.RELAY + override val capabilities = LegRouting.RELAY_CAPABILITIES + override suspend fun git(verb: GitVerb, cwd: String, args: Map): JsonElement? { + calls += Triple(verb, cwd, args) + return answer(verb) + } + + override suspend fun listProjects(): ProjectsSnapshot = error("unused") + override suspend fun attach(nodeId: String, cols: Int, rows: Int, sink: TerminalSink, create: NewSessionHint?): TerminalStream = + error("unused") + override suspend fun wake(nodeId: String) { error("unused") } + override suspend fun refresh(nodeId: String) { error("unused") } + override suspend fun rename(nodeId: String, title: String) { error("unused") } + override suspend fun ensureBoard(projectId: String): List? = error("unused") + override suspend fun setCardColumn(projectId: String, nodeId: String, columnId: String?): Boolean = error("unused") + override suspend fun editCardLabels(projectId: String, nodeId: String, edit: CardLabelEdit): LabelEditResult? = error("unused") + override suspend fun registerNode(projectId: String, node: NewNode): Boolean = error("unused") + override suspend fun answerApproval(event: InboxEvent, allow: Boolean): ApprovalOutcome = error("unused") + override suspend fun ackRead(nodeId: String, eventId: String?) { error("unused") } + override suspend fun sendKeys(nodeId: String, keys: String) { error("unused") } + override fun setOnChanged(listener: (() -> Unit)?) {} + override fun setOnClosed(listener: ((String?) -> Unit)?) {} + override fun close() {} + } + + private val ok = Json.parseToJsonElement("""{"ok":true,"message":""}""") + + @Test + fun `each verb sends the parameters host-service reads, and only those`() = runBlocking { + val conn = RecordingConn { verb -> if (verb == GitVerb.DIFF) JsonPrimitive("") else ok } + val git = SourceControl(conn, "/work/app") + val untracked = GitFileChange("new.txt", "U", 0, 0) + val changed = GitFileChange("a.ts", "M", 1, 1) + git.diff(untracked, staged = false) + git.diff(changed, staged = false) + git.diff(changed, staged = true) + git.diff(GitConflict("both.ts", "U", "U")) + git.stage(listOf("a.ts", "new.txt")) + git.unstage(listOf("a.ts")) + git.commit("msg") + git.push() + git.pull() + + val bool = { b: Boolean -> JsonPrimitive(b) } + assertEquals( + listOf( + GitVerb.DIFF to mapOf("path" to JsonPrimitive("new.txt"), "staged" to bool(false), "untracked" to bool(true)), + GitVerb.DIFF to mapOf("path" to JsonPrimitive("a.ts"), "staged" to bool(false), "untracked" to bool(false)), + GitVerb.DIFF to mapOf("path" to JsonPrimitive("a.ts"), "staged" to bool(true), "untracked" to bool(false)), + // An unmerged path's working tree, never the untracked (whole file as new) form. + GitVerb.DIFF to mapOf("path" to JsonPrimitive("both.ts"), "staged" to bool(false), "untracked" to bool(false)), + GitVerb.STAGE to mapOf("paths" to JsonArray(listOf(JsonPrimitive("a.ts"), JsonPrimitive("new.txt")))), + GitVerb.UNSTAGE to mapOf("paths" to JsonArray(listOf(JsonPrimitive("a.ts")))), + GitVerb.COMMIT to mapOf("message" to JsonPrimitive("msg")), + GitVerb.PUSH to emptyMap(), + GitVerb.PULL to emptyMap() + ), + conn.calls.map { it.first to it.third } + ) + assertTrue(conn.calls.all { it.second == "/work/app" }, "every verb names the project's folder") + assertEquals( + listOf("git.status", "git.diff", "git.stage", "git.unstage", "git.commit", "git.push", "git.pull", "git.history"), + GitVerb.entries.map { it.wire }, + "the verbs host-service.ts routes to handleGit" + ) + } + + @Test + fun `a reply the phone cannot read is said so, never shown as an empty repository`() = runBlocking { + val conn = RecordingConn { JsonPrimitive(42) } + val git = SourceControl(conn, "/work/app") + assertFailsWith { git.status() } + assertFailsWith { git.history() } + assertFailsWith { git.stage(listOf("a")) } + assertFailsWith { git.diff(GitFileChange("a", "M", 0, 0), staged = false) } + } + + @Test + fun `every write waits longer than the usual request, the reads do not`() { + // The desktop sets no limit on any git command: a push to a slow remote, a commit running + // the repository's hooks, a stage running LFS clean filters must not read as failures. + val writes = setOf(GitVerb.STAGE, GitVerb.UNSTAGE, GitVerb.COMMIT, GitVerb.PUSH, GitVerb.PULL) + for (v in writes) assertEquals(GIT_WRITE_TIMEOUT_MS, v.timeoutMs, "$v") + assertTrue(GIT_WRITE_TIMEOUT_MS > RelaySocket.RPC_TIMEOUT_MS) + for (v in GitVerb.entries - writes) assertEquals(RelaySocket.RPC_TIMEOUT_MS, v.timeoutMs, "$v") + val relay = java.io.File(InteropHarness.repoRoot, "android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt").readText() + assertTrue(relay.contains("verb.timeoutMs"), "the relay connection must pass the verb's timeout on") + assertTrue(relay.contains("throw hostException(e)"), "the relay connection must keep an unanswered request unanswered") + } + + @Test + fun `a write that got no answer says it may have run, a refusal stays the host's sentence`() = runBlocking { + // The relay's own failures, as the connection surfaces them. + assertIs(hostException(RpcUnansweredException("RPC timed out: git.commit"))) + assertIs(hostException(RpcUnansweredException("Relay connection closed."))) + val refused = hostException(RpcException("cwd is outside the shared project roots.")) + assertFalse(refused is HostUnansweredException) + assertEquals("cwd is outside the shared project roots.", refused.message) + + val timedOut = SourceControl(RecordingConn { throw HostUnansweredException("RPC timed out: git.commit") }, "/w") + val e = assertFailsWith { timedOut.commit("msg") } + assertEquals("The computer did not answer. The commit may still be running there, or may have finished.", e.message) + assertEquals(SourceControl.unanswered("The push"), assertFailsWith { timedOut.push() }.message) + + val refusing = SourceControl(RecordingConn { throw HostException("git is not served on this host.") }, "/w") + val r = assertFailsWith { refusing.commit("msg") } + assertFalse(r is HostUnansweredException) + assertEquals("git is not served on this host.", r.message) + } + + @Test + fun `the status is read again after every write, whatever came of it`() = runBlocking { + var reloads = 0 + val reload: suspend () -> Unit = { reloads++ } + + // No answer: the commit may have landed, so the screen must not keep the old status. + val unanswered = SourceControl.writeThenReload("Commit", { throw HostUnansweredException(SourceControl.unanswered("The commit")) }, reload) + assertEquals(1, reloads) + assertEquals(SourceControl.Outcome(error = true, text = SourceControl.unanswered("The commit")), unanswered) + + // git refused it there: its own words, and the truth about ahead/behind. + val failed = SourceControl.writeThenReload("Push", { GitResult(false, "rejected") }, reload) + assertEquals(2, reloads) + assertEquals(SourceControl.Outcome(error = true, text = "rejected"), failed) + + assertEquals(SourceControl.Outcome(false, "Pushed."), SourceControl.writeThenReload("Push", { GitResult(true, "Pushed.") }, reload)) + assertNull(SourceControl.writeThenReload("Stage", { GitResult(true, "") }, reload)) + assertEquals(4, reloads) + + // A failed re-read is said only when the write itself had nothing to say. + val broken: suspend () -> Unit = { throw HostException("Relay socket is not connected.") } + assertEquals(SourceControl.Outcome(true, "Relay socket is not connected."), SourceControl.writeThenReload("Stage", { GitResult(true, "") }, broken)) + assertEquals(SourceControl.Outcome(true, "rejected"), SourceControl.writeThenReload("Push", { GitResult(false, "rejected") }, broken)) + + // Leaving the screen cancels; that is not an outcome. + assertFailsWith { + SourceControl.writeThenReload("Commit", { throw kotlinx.coroutines.CancellationException("left") }, reload) + } + } + + // ---- the app ------------------------------------------------------------------------------------ + + /** + * The screen cannot run on a JVM, so its wiring is pinned in the source ([AppSourcePins]): it + * decides from the one routing decision and the gate, and every git call goes through the leg + * that routing names, so on the LAN it is the relay leg next to SSH, never the SSH connection. + */ + @Test + fun `the screen asks the routing and the gate, and sends git through the leg they name`() { + val screen = AppSourcePins.ui("SourceControlScreen.kt") + assertTrue(screen.contains("SourceControlGate.of(project, session.route(Capability.GIT))")) + assertTrue(screen.contains("SourceControl(session.connectionFor(Capability.GIT), dir)")) + assertFalse(screen.contains("ensureConnected()"), "git must not go to whatever connection is open") + assertFalse(Regex("""connection\??\.git\(""").containsMatchIn(screen), "git must not go to whatever connection is open") + // The cwd is the gate's (the project's folder from projects.list), never typed or derived. + assertTrue(screen.contains("(gate as? SourceControlGate.Availability.Available)?.cwd")) + // A git failure on the computer, and a refused request, are shown in its own words, and the + // status is read again after every write, failed or unanswered included. + assertTrue(screen.contains("notice = SourceControl.writeThenReload(label, { block(git(dir)) }, { load(dir, withHistory) })")) + assertTrue(screen.contains("e.message ?:")) + } + + @Test + fun `the screen lists conflicts on their own, and no row can share a key with a heading`() { + val screen = AppSourcePins.ui("SourceControlScreen.kt") + // A path is any name, `title` or `header` included, so rows and headings live in disjoint + // namespaces: a duplicate LazyColumn key throws, i.e. the screen would crash on open. + assertTrue(screen.contains("""item(key = "${'$'}key-header")""")) + assertTrue(screen.contains("""items(files, key = { "${'$'}key-file:${'$'}{it.path}" })""")) + assertTrue(screen.contains("""items(conflicts, key = { "conflicts-file:${'$'}{it.path}" })""")) + val keys = Regex("""key = \{? ?"([^"]*)"""").findAll(screen).map { it.groupValues[1] }.toList() + for (k in keys.filterNot { "{it." in it }) { + assertFalse(Regex("""-file:""").containsMatchIn(k), "a fixed key in the row namespace: $k") + } + assertFalse(screen.contains("""key-${'$'}{it.path}"""), "the old row key shared the heading's namespace") + // Conflicts are opened as conflicts (never the untracked diff) and offered no Stage. + assertTrue(screen.contains("conflictSection(status.conflicts, onOpenConflict)")) + assertTrue(screen.contains("is DiffTarget.Conflict -> git(dir).diff(target.conflict)")) + val section = screen.substringAfter("LazyListScope.conflictSection(").substringBefore("@Composable") + assertTrue(section.contains("onOpen(c)")) + assertFalse(section.contains("TextButton") || section.contains("onAct"), "a conflict row offers no action but opening it") + } + + @Test + fun `the Sessions and Board project headers open it, and the back stack keeps it`() { + assertTrue(AppSourcePins.ui("SessionsTab.kt").contains("nav.push(Route.SourceControl(hostId, project.id))")) + assertTrue(AppSourcePins.ui("BoardTab.kt").contains("nav.push(Route.SourceControl(hostId, project.id))")) + val main = AppSourcePins.app("MainActivity.kt") + assertTrue(main.contains("is Route.SourceControl -> listOf(\"git\", r.hostId, r.projectId)")) + assertTrue(main.contains("\"git\" -> if (parts.size == 3) Route.SourceControl(parts[1], parts[2]) else null")) + assertTrue(main.contains("is Route.SourceControl -> graph.hosts.get(r.hostId) != null"), "a forgotten computer's screen is dropped") + assertTrue(main.contains("is Route.SourceControl -> SourceControlScreen(nav, r.hostId, r.projectId)")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshActionsInteropTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshActionsInteropTest.kt new file mode 100644 index 0000000000..2e2bfe60f3 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshActionsInteropTest.kt @@ -0,0 +1,102 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.HostUnansweredException +import dev.nodeterm.protocol.model.J.s +import dev.nodeterm.protocol.ssh.SshActions +import dev.nodeterm.protocol.ssh.SshActionsScripts +import kotlinx.serialization.json.* +import java.io.File +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import org.junit.jupiter.api.Assumptions.assumeTrue +import kotlin.test.* + +/** Actual TypeScript WorkspaceStore producer ↔ Kotlin consumer ↔ generated POSIX filesystem writer. */ +class SshActionsInteropTest { + private fun fixture(body: (InteropHarness, File, SshActions) -> Unit) { + assumeTrue(!System.getProperty("os.name").startsWith("Windows"), "Direct SSH actions need a POSIX host") + val home = Files.createTempDirectory("nt-actions-interop-").toFile() + try { + val ud = File(home, ".config/node-terminal") + InteropHarness.start("ssh-actions", InteropHarness.scratchHomeEnv(home) + mapOf("FIXTURE_USERDATA" to ud.path)).use { f -> + assertEquals(ud.path, f.ready.s("userData")) + body(f, ud, SshActions { script, _, input, _, _ -> execute(script, input) }) + } + } finally { home.deleteRecursively() } + } + private fun process(script: String): Process = ProcessBuilder("/bin/sh", "-c", script).apply { + environment().clear(); environment()["PATH"] = "/usr/bin:/bin" + }.start() + private fun execute(script: String, input: String?): Pair { + val p = process(script) + try { + p.outputStream.use { input?.let { text -> it.write(text.toByteArray(Charsets.UTF_8)) } } + var raw = ""; var error = "" + val stdout = thread { raw = p.inputStream.bufferedReader().readText() } + val stderr = thread { error = p.errorStream.bufferedReader().readText() } + assertTrue(p.waitFor(22, TimeUnit.SECONDS), "generated SSH action exceeded its own deadline") + stdout.join(); stderr.join(); assertTrue(error.isBlank(), error) + return p.exitValue() to raw + } finally { p.destroyForcibly() } + } + private fun request(a: SshActions.Advertisement, nonce: String, at: Long = a.updatedAt, method: String = "projects.ensureBoard", params: JsonObject = buildJsonObject { put("projectId", "ssh-actions-project") }) = + buildJsonObject { put("version", 1); put("instance", a.instance); put("nonce", nonce); put("issuedAt", at); put("method", method); put("params", params) }.toString() + private fun snapshot(f: InteropHarness): JsonObject { f.command("snapshot"); return f.awaitEvent("snapshot") } + + @Test fun `actual producer accepts typed board schemas and node nudges through the private writer`() = fixture { f, ud, client -> + fun call(method: String, params: JsonObject) = client.call(ud.path, method, params, "term-ssh-actions") { true }.jsonObject + val board = call("projects.ensureBoard", buildJsonObject { put("projectId", "ssh-actions-project") }) + val columns = board.getValue("columns").jsonArray + assertEquals(3, columns.size) + val column = columns[1].jsonObject.getValue("id").jsonPrimitive.content + assertTrue(call("projects.setCardColumn", buildJsonObject { put("projectId", "ssh-actions-project"); put("nodeId", "term-ssh-actions"); put("columnId", column) }).getValue("moved").jsonPrimitive.boolean) + val labeled = call("projects.editCardLabels", buildJsonObject { + put("projectId", "ssh-actions-project"); put("nodeId", "term-ssh-actions") + put("create", JsonArray(listOf(buildJsonObject { put("name", "Kotlin α ' scope"); put("color", "blue") }))) + }) + assertTrue(labeled.getValue("edited").jsonPrimitive.boolean) + assertEquals("Kotlin α ' scope", labeled.getValue("labels").jsonArray.single().jsonObject.s("name")) + for (verb in listOf("wake", "refresh", "rename")) { + assertTrue(call("node.$verb", buildJsonObject { put("nodeId", "term-ssh-actions"); if (verb == "rename") put("title", "α ' title") }).getValue("delivered").jsonPrimitive.boolean) + } + val proof = snapshot(f) + val persisted = proof.getValue("file").jsonObject.getValue("kanban").jsonObject + assertEquals(column, persisted.getValue("assignments").jsonArray.single().jsonObject.s("columnId")) + assertEquals(labeled.getValue("cardLabelIds"), persisted.getValue("meta").jsonArray.single().jsonObject["labels"]) + assertEquals(listOf("wake", "refresh", "rename"), proof.getValue("nudges").jsonArray.map { it.jsonObject.s("method") }) + assertTrue(proof.getValue("broadcasts").jsonArray.any { it.toString().contains("workspace:server-change") }, "must announce the real save chain") + } + @Test fun `actual producer refuses an expired immutable request without changing the board`() = fixture { f, ud, client -> + val a = client.probe(ud.path)!!; val nonce = UUID.randomUUID().toString() + val (code, raw) = execute(SshActionsScripts.submit(ud.path, a, nonce), request(a, nonce, at = a.updatedAt - 600001)) + assertEquals(0, code) + assertFailsWith { SshActions.parseResponse(raw.substringAfter('\n').trim(), a.instance, nonce) } + assertNull(snapshot(f).getValue("file").jsonObject["kanban"]) + } + @Test fun `actual producer restart during stdin is refused before publication into either instance`() = fixture { f, ud, client -> + val a = client.probe(ud.path)!!; val nonce = UUID.randomUUID().toString() + val p = process(SshActionsScripts.submit(ud.path, a, nonce)) + try { + p.outputStream.write("{".toByteArray()); p.outputStream.flush() + val old = File(ud, "ssh-actions/${a.instance}") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (old.listFiles()!!.none { it.name.endsWith(".upload") }) { assertTrue(System.nanoTime() < deadline); Thread.sleep(5) } + f.command("restart"); f.awaitEvent("restarted") + p.outputStream.use { it.write(request(a, nonce).removePrefix("{").toByteArray()) } + assertTrue(p.waitFor(5, TimeUnit.SECONDS)); assertEquals(3, p.exitValue()) + assertEquals(SshActionsScripts.UNAVAILABLE, p.inputStream.bufferedReader().readText().trim()) + assertFalse(File(old, "$nonce.request").exists()) + assertNull(snapshot(f).getValue("file").jsonObject["kanban"]) + } finally { p.destroyForcibly() } + } + @Test fun `actual producer scope refusal is a confirmed answer and never a replay`() = fixture { f, ud, client -> + val e = assertFailsWith { + client.call(ud.path, "node.wake", buildJsonObject { put("nodeId", "unowned-node") }, "unowned-node") { true } + } + assertFalse(e is HostUnansweredException) + assertTrue(snapshot(f).getValue("nudges").jsonArray.isEmpty()) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshActionsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshActionsTest.kt new file mode 100644 index 0000000000..15894ad235 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshActionsTest.kt @@ -0,0 +1,193 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.* +import dev.nodeterm.protocol.model.* +import dev.nodeterm.protocol.ssh.SshActions +import dev.nodeterm.protocol.ssh.SshActionsScripts +import kotlinx.serialization.json.* +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import org.junit.jupiter.api.Assumptions.assumeTrue +import kotlin.test.* + +class SshActionsTest { + private val instance = "10000000-0000-4000-8000-000000000001" + private val nonce = "20000000-0000-4000-8000-000000000002" + private val now = 1_700_000_000_000L + private fun raw(at: Long = now, remote: Boolean = true, methods: Set = SshActions.ALL) = buildJsonObject { + put("version", 1); put("instance", instance); put("pid", 10); put("updatedAt", at) + put("methods", JsonArray(methods.map(::JsonPrimitive))); put("remoteProjects", remote) + }.toString() + private fun advert(at: Long = now, remote: Boolean = true, methods: Set = SshActions.ALL) = + SshActions.parseAdvertisement("NT-ACTIONS-1\t$now\n${raw(at, remote, methods)}\n")!! + private fun response(n: String = nonce, i: String = instance, extra: String = "\"ok\":true,\"result\":{\"delivered\":true}") = + "{\"version\":1,\"instance\":\"$i\",\"nonce\":\"$n\",$extra}" + private fun node(id: String = "term-1") = NodeInfo(id, NodeKind.TERMINAL, "T", null, null, null, null, null, null, null, null) + private fun project(remote: String? = null, driven: Boolean = false, nodes: List = listOf(node())) = + ProjectInfo("project-1", "P", null, "/project", remote, false, nodes, null, drivenRemotely = driven) + + @Test fun `advertisements need exact version instance owner and a current host-clock heartbeat`() { + assertNotNull(SshActions.parseAdvertisement("NT-ACTIONS-1\t$now\n${raw()}")) + for (bad in listOf(raw(now - 15001), raw(now + 30001), raw().replace(instance, "not-uuid"), + raw().replace("\"pid\":10", "\"pid\":0"), raw().replace("\"version\":1", "\"version\":\"1\""), + raw().replace("\"updatedAt\":$now", "\"updatedAt\":\"$now\""))) { + assertNull(SshActions.parseAdvertisement("NT-ACTIONS-1\t$now\n$bad"), bad) + } + assertNull(SshActions.parseAdvertisement("wrong\t$now\n${raw()}")) + } + @Test fun `capabilities are advertised as complete groups and registration stays unavailable`() { + var value = raw(methods = setOf("node.wake")) + val a = SshActions { _, _, _, _, _ -> 0 to "NT-ACTIONS-1\t$now\n$value" } + a.probe("/profile") + assertFalse(a.capabilities.nodeActions); assertFalse(a.capabilities.boardWrites); assertFalse(a.capabilities.registerNode) + value = raw(); a.probe("/profile") + assertTrue(a.capabilities.nodeActions); assertTrue(a.capabilities.boardWrites) + a.clear(); assertFalse(a.capabilities.boardWrites) + value = raw(now - 15000); a.probe("/profile"); assertFalse(a.capabilities.nodeActions) + } + @Test fun `a request keeps one nonce and the advertisement timestamp across upload`() { + var writes = 0 + val a = SshActions { _, timeout, input, write, limit -> + if (input == null) 0 to "NT-ACTIONS-1\t$now\n${raw()}" else { + writes++; assertTrue(write); assertEquals(20L, timeout); assertEquals(131200, limit) + val request = Json.parseToJsonElement(input).jsonObject + assertEquals(now, request.getValue("issuedAt").jsonPrimitive.long) + assertEquals(instance, request.getValue("instance").jsonPrimitive.content) + val n = request.getValue("nonce").jsonPrimitive.content; assertTrue(SshActions.UUID.matches(n)) + 0 to "NT-ACTIONS-REPLY\n${response(n)}\n" + } + } + assertEquals(true, a.call("/profile", "node.wake", buildJsonObject { put("nodeId", "term-1") }, "term-1") { true }.jsonObject["delivered"]?.jsonPrimitive?.boolean) + assertEquals(1, writes) + } + @Test fun `unavailable capability or ownership is refused before a write`() { + for ((methods, owns) in listOf(emptySet() to true, SshActions.ALL to false)) { + var writes = 0 + val a = SshActions { _, _, input, _, _ -> if (input != null) { writes++; 0 to "" } else 0 to "NT-ACTIONS-1\t$now\n${raw(methods = methods)}" } + assertFailsWith { a.call("/profile", "node.wake", JsonObject(emptyMap()), "term-1") { owns } } + assertEquals(0, writes) + } + } + @Test fun `after submission unanswered outcomes never become relay fallback or an automatic retry`() { + for (out in listOf(null to "", 4 to "NT-ACTIONS-UNCERTAIN", 0 to "bad", 3 to "other refusal")) { + var writes = 0 + val a = SshActions { _, _, input, _, _ -> if (input == null) 0 to "NT-ACTIONS-1\t$now\n${raw()}" else { writes++; out } } + assertFailsWith { a.call("/profile", "node.wake", JsonObject(emptyMap()), "term-1") { true } } + assertEquals(1, writes) + } + } + @Test fun `a confirmed prepublication descriptor refusal remains safe for relay`() { + val a = SshActions { _, _, input, _, _ -> if (input == null) 0 to "NT-ACTIONS-1\t$now\n${raw()}" else 3 to SshActionsScripts.UNAVAILABLE } + assertFailsWith { a.call("/profile", "node.wake", JsonObject(emptyMap()), "term-1") { true } } + assertFalse(a.capabilities.nodeActions) + } + @Test fun `reply identity shape and uncertainty must be confirmed while a real refusal is an answer`() { + for (bad in listOf(response(i = nonce), response(n = instance), response().replace("\"version\":1", "\"version\":2"), + response(extra = "\"ok\":true"), response(extra = "\"ok\":true,\"result\":{},\"uncertain\":true"), + response(extra = "\"ok\":\"true\",\"result\":{}"), "x".repeat(131073))) { + assertFailsWith { SshActions.parseResponse(bad, instance, nonce) } + } + val e = assertFailsWith { SshActions.parseResponse(response(extra = "\"ok\":false,\"error\":\"Owned node not found\""), instance, nonce) } + assertFalse(e is HostUnansweredException); assertEquals("Owned node not found", e.message) + } + @Test fun `profile ownership excludes driven synthetic duplicate and foreign nodes but permits desktop SSH refs`() { + assertTrue(SshActions.owns(listOf(project()), "project-1", "term-1", advert())) + assertTrue(SshActions.owns(listOf(project("user@other")), "project-1", "term-1", advert())) + assertFalse(SshActions.owns(listOf(project("user@other")), "project-1", "term-1", advert(remote = false))) + assertFalse(SshActions.owns(listOf(project(driven = true)), "project-1", "term-1", advert())) + assertFalse(SshActions.owns(listOf(project()), "foreign", "term-1", advert())) + assertFalse(SshActions.owns(listOf(project()), "project-1", "missing", advert())) + assertFalse(SshActions.owns(listOf(project(), project()), "project-1", "term-1", advert())) + assertFalse(SshActions.owns(listOf(project(nodes = listOf(node(), node()))), "project-1", "term-1", advert())) + } + @Test fun `request size is limited before dispatch`() { + var writes = 0 + val a = SshActions { _, _, input, _, _ -> if (input != null) { writes++; 0 to "" } else 0 to "NT-ACTIONS-1\t$now\n${raw()}" } + assertFailsWith { a.call("/profile", "node.rename", buildJsonObject { put("title", "é".repeat(70000)) }, "term-1") { true } } + assertEquals(0, writes) + } + + private fun posix() = assumeTrue(!System.getProperty("os.name").startsWith("Windows"), "SSH companion scripts need a POSIX filesystem host") + private fun File.privateDir(): File = apply { mkdirs(); Files.setPosixFilePermissions(toPath(), PosixFilePermissions.fromString("rwx------")) } + private fun File.privateFile(text: String): File = apply { writeText(text); Files.setPosixFilePermissions(toPath(), PosixFilePermissions.fromString("rw-------")) } + private fun fixture(body: (File, File, File) -> Unit) { + posix(); val home = Files.createTempDirectory("nt-ssh-actions-").toFile() + try { + val ud = File(home, "profile '$ quoted").privateDir() + val root = File(ud, "ssh-actions").privateDir(); val dir = File(root, instance).privateDir() + File(root, "advertisement.json").privateFile(raw(System.currentTimeMillis())) + body(ud, root, dir) + } finally { home.deleteRecursively() } + } + private fun shell(script: String): Process = ProcessBuilder("/bin/sh", "-c", script).apply { + environment().clear(); environment()["PATH"] = "/usr/bin:/bin" + }.start() + private fun finish(p: Process): Pair { + assertTrue(p.waitFor(5, TimeUnit.SECONDS), "shell did not finish") + val error = p.errorStream.bufferedReader().readText() + val output = p.inputStream.bufferedReader().readText() + assertTrue(error.isBlank(), error) + return p.exitValue() to output + } + private fun liveAd(ud: File): SshActions.Advertisement { + val p = shell(SshActionsScripts.probe(ud.path)); p.outputStream.close() + val (exit, out) = finish(p); assertEquals(0, exit, out) + return SshActions.parseAdvertisement(out)!! + } + @Test fun `real shell probes only the selected quoted private profile and refuses symlinks modes and oversized files`() = fixture { ud, root, _ -> + val ad = File(root, "advertisement.json"); val raw = ad.readText() + assertEquals(instance, liveAd(ud).instance) + for (bad in listOf("symlink", "world-readable", "hardlink", "oversize", "unsafe-profile")) { + ad.delete(); ad.privateFile(raw); Files.setPosixFilePermissions(ud.toPath(), PosixFilePermissions.fromString("rwx------")) + val other = File(root, "other"); other.delete() + when (bad) { + "symlink" -> { ad.delete(); other.privateFile(raw); Files.createSymbolicLink(ad.toPath(), other.toPath()) } + "world-readable" -> Files.setPosixFilePermissions(ad.toPath(), PosixFilePermissions.fromString("rw-r--r--")) + "hardlink" -> Files.createLink(other.toPath(), ad.toPath()) + "oversize" -> ad.privateFile("x".repeat(131073)) + "unsafe-profile" -> Files.setPosixFilePermissions(ud.toPath(), PosixFilePermissions.fromString("rwxrwx---")) + } + val p = shell(SshActionsScripts.probe(ud.path)); p.outputStream.close() + assertEquals(3, finish(p).first, bad) + } + } + @Test fun `real upload checks the descriptor again after stdin without treating heartbeats as restarts`() = fixture { ud, root, dir -> + for (restart in listOf(false, true)) { + dir.listFiles()!!.forEach { it.delete() } + File(root, "advertisement.json").privateFile(raw(System.currentTimeMillis())) + val a = liveAd(ud); val p = shell(SshActionsScripts.submit(ud.path, a, nonce)) + p.outputStream.write("{\"partial\":".toByteArray()); p.outputStream.flush() + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (dir.listFiles()!!.none { it.name.endsWith(".upload") }) { assertTrue(System.nanoTime() < deadline); Thread.sleep(5) } + File(root, "advertisement.json").privateFile(raw(System.currentTimeMillis()).let { if (restart) it.replace(instance, this.nonce) else it }) + val producer = if (!restart) thread { + val request = File(dir, "$nonce.request") + while (!request.exists() && System.nanoTime() < deadline) Thread.sleep(5) + assertTrue(request.exists()); File(dir, "$nonce.response").privateFile(response()) + } else null + p.outputStream.use { it.write("true}".toByteArray()) } + val (code, out) = finish(p); producer?.join() + assertEquals(if (restart) 3 else 0, code, out) + if (restart) assertFalse(File(dir, "$nonce.request").exists()) else assertTrue(out.startsWith("NT-ACTIONS-REPLY\n")) + assertTrue(dir.listFiles()!!.none { it.name.endsWith(".upload") }) + } + } + @Test fun `real publication never replaces an existing nonce and rejects unsafe responses`() = fixture { ud, _, dir -> + val ad = liveAd(ud) + File(dir, "$nonce.request").privateFile("original") + val collision = shell(SshActionsScripts.submit(ud.path, ad, nonce)); collision.outputStream.use { it.write("replacement".toByteArray()) } + assertEquals(4, finish(collision).first); assertEquals("original", File(dir, "$nonce.request").readText()) + File(dir, "$nonce.request").delete() + val p = shell(SshActionsScripts.submit(ud.path, ad, nonce)) + val producer = thread { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (!File(dir, "$nonce.request").exists() && System.nanoTime() < deadline) Thread.sleep(5) + Files.createSymbolicLink(File(dir, "$nonce.response").toPath(), File(dir, "$nonce.request").toPath()) + } + p.outputStream.use { it.write("{}".toByteArray()) } + assertEquals(4, finish(p).first); producer.join() + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshActionsWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshActionsWiringTest.kt new file mode 100644 index 0000000000..aa2ac8db71 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshActionsWiringTest.kt @@ -0,0 +1,44 @@ +package dev.nodeterm.protocol + +import java.io.File +import kotlin.test.* + +/** Native adapter pins supplement executed model/shell/producer tests; no Android runtime required. */ +class SshActionsWiringTest { + private fun source() = File(InteropHarness.repoRoot, "android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt").readText().replace("\r\n", "\n") + @Test fun `cached capabilities preserve independent Git and never promise cold registration`() { + val s = source() + assertTrue(s.contains("actions.capabilities.copy(git = true)")) + val model = File(InteropHarness.repoRoot, "android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshActions.kt").readText() + assertTrue(model.contains("registerNode = false")) + assertTrue(s.contains("registerNode(projectId: String, node: NewNode): Boolean = relayOnly")) + } + @Test fun `profile and ownership are one captured listing before a service call`() { + val s = source() + assertTrue(s.contains("ActionListing(ud, own.projects.filterNot { it.drivenRemotely })")) + assertTrue(s.contains("val listing = actionListing")) + assertTrue(s.contains("actions.call(listing.userData, method, params")) + assertTrue(s.contains("SshActions.owns(listing.projects, projectId, nodeId, it)")) + assertFalse(s.contains("actions.call(userData, method")) + } + @Test fun `each advertised mutation uses typed service APIs with confirmed replies`() { + val s = source() + for (verb in listOf("node.wake", "node.refresh", "node.rename", "projects.ensureBoard", "projects.setCardColumn", "projects.editCardLabels")) assertTrue(s.contains("\"$verb\""), verb) + assertTrue(s.contains("if (!result.flag(\"delivered\"))")) + assertTrue(s.contains("result as? JsonObject ?: SshActions.uncertain()")) + assertTrue(s.contains("}).flag(\"moved\")")) + } + @Test fun `stdin write bounded output and honest transport uncertainty reach finalized run`() { + val s = source() + assertTrue(s.contains("run(script, timeout, stdin, uncertainWrite = write, outputLimit = limit)")) + assertTrue(s.contains("SSH output exceeded the ${'$'}outputLimit byte limit")) + assertTrue(s.contains("if (uncertainWrite && dispatched) throw HostUnansweredException(message)")) + } + @Test fun `retired transport and empty listings retire advertised service capabilities`() { + val s = source() + val close = s.substringAfter("private fun fireClosed(reason: String?) {").substringBefore("private fun ") + assertTrue(close.contains("actions.clear()")) + val missing = s.substringAfter("if (out.nothingFound(now)) {").substringBefore("throw NothingFoundException()") + assertTrue(missing.contains("ActionListing(null, emptyList())")); assertTrue(missing.contains("actions.clear()")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshComposedInputTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshComposedInputTest.kt new file mode 100644 index 0000000000..0e07543224 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshComposedInputTest.kt @@ -0,0 +1,41 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ComposedInput +import dev.nodeterm.protocol.ssh.ManagedViewHandshake +import dev.nodeterm.protocol.ssh.SshComposedInput +import java.io.ByteArrayInputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class SshComposedInputTest { + private val tty = "/dev/pts/3" + private val birth = "linux:12345678-1234-1234-1234-123456789abc:123" + private val raw = "NT-COMPOSED-VIEW|$tty|101|123456|102|nt-term-abc-1|123456|\$0|%1|103|$birth|$birth|$birth\n" + + @Test fun `capture receipt requires exact tty session fields and bounded process births`() { + val proof = assertNotNull(SshComposedInput.parse(raw, tty, "term-abc-1")) + assertEquals(101, proof.viewerPid); assertEquals(103, proof.panePid) + for (bad in listOf(raw.replace(tty, "/dev/pts/4"), raw.replace("nt-term-abc-1", "nt-term-other-1"), + raw.replace("|%1|", "|%1;touch /tmp/wrong|"), raw.replace("|101|", "|-1|"), + raw.replace(birth, "linux:wrong:123"), raw.trim() + "|extra", raw.replace("|123456|\$0", "|0|\$0"))) { + assertNull(SshComposedInput.parse(bad, tty, "term-abc-1"), bad) + } + } + + @Test fun `private normal-view handshake consumes only its bounded tty line`() { + val input = ByteArrayInputStream("NT-INPUT-VIEW $tty\n\u001b[2Jactual output".toByteArray()) + assertEquals(tty, ManagedViewHandshake.read(input, "NT-INPUT-VIEW ")) + assertEquals("\u001b[2Jactual output", input.readBytes().toString(Charsets.UTF_8)) + } + + @Test fun `composed SSH script carries identity and framing instructions without draft on argv`() { + val proof = assertNotNull(SshComposedInput.parse(raw, tty, "term-abc-1")) + val input = "private full prompt with \"quotes\", \$HOME and newlines\n" + val script = SshComposedInput.send("term-abc-1", "node-terminal", null, proof, ComposedInput.Paste(input, false)) + assertFalse(script.contains(input), "text only goes through bounded SSH stdin") + assertFalse(script.contains("send-keys -l"), "tmux bracketed paste remains the composed path") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshFallbackTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshFallbackTest.kt new file mode 100644 index 0000000000..07bae584c4 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshFallbackTest.kt @@ -0,0 +1,154 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.ssh.HostKeyChangedException +import dev.nodeterm.protocol.ssh.HostKeyNotPairedException +import dev.nodeterm.protocol.ssh.SshFallback +import dev.nodeterm.protocol.ssh.SshFallback.Next +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Audit A49/A74: what a connect does after the direct-SSH leg failed. */ +class SshFallbackTest { + private val changed = HostKeyChangedException("SHA256:paired", "SHA256:whoever-has-the-address-now") + private val relaySetting = "“Only through the relay”" + + @Test + fun `in Auto a changed host key goes on to the relay, and says so even when the relay works`() { + val next = SshFallback.afterFailure(changed, relayAllowed = true, relayConfigured = true) + assertIs(next) + // Shown if the relay fails as well… + assertTrue(next.error.startsWith("On your network: ")) + assertTrue(next.error.contains("SHA256:whoever-has-the-address-now")) + // …and while connected through it, so the change never disappears behind a working relay. + val warning = assertNotNull(next.warning) + assertTrue(warning.contains("SHA256:paired") && warning.contains("SHA256:whoever-has-the-address-now")) + assertTrue(warning.contains("through the relay instead")) + assertTrue(warning.contains(relaySetting), warning) + // A changed pin keeps the changed-pin advice: re-reading the computer's keys trusts new ones. + assertTrue(warning.contains(SshFallback.REPAIR_NOTE), warning) + assertTrue(next.error.contains(SshFallback.REPAIR_NOTE), next.error) + } + + /** + * Review of A74-refresh: the key the server presented goes on with the relay leg, so that relay + * connection's report can confirm it as the computer's own ([dev.nodeterm.protocol.ssh.LanRefresh]). + * Text alone dropped it, and a pin still among the reported keys then never gave way. + */ + @Test + fun `the relay leg is handed the key the SSH server was refused for, and only that`() { + val next = assertIs(SshFallback.afterFailure(changed, relayAllowed = true, relayConfigured = true)) + assertEquals("SHA256:whoever-has-the-address-now", next.refusedHostKey) + val notReported = HostKeyNotPairedException(listOf("SHA256:reported"), "SHA256:presented") + assertEquals( + "SHA256:presented", + assertIs(SshFallback.afterFailure(notReported, relayAllowed = true, relayConfigured = true)).refusedHostKey + ) + // Any other failure says nothing about the computer's key. + val down = HostException("Couldn't connect over SSH to dev@10.0.0.2:22 (timeout).") + assertNull(assertIs(SshFallback.afterFailure(down, relayAllowed = true, relayConfigured = true)).refusedHostKey) + } + + @Test + fun `Only on my network keeps the hard stop, and names the way out`() { + val next = SshFallback.afterFailure(changed, relayAllowed = false, relayConfigured = true) + assertIs(next) + assertTrue(next.message.contains("SHA256:whoever-has-the-address-now")) + assertTrue(next.message.contains(relaySetting), next.message) + assertTrue(next.message.contains("Settings → How to reach each computer")) + } + + @Test + fun `with no relay leg a changed key stops, and says how to get one`() { + for (allowed in listOf(true, false)) { + val next = SshFallback.afterFailure(changed, relayAllowed = allowed, relayConfigured = false) + assertIs(next) + // The relay-only setting would not help here: do not send the user to it. + assertFalse(next.message.contains(relaySetting), next.message) + assertTrue(next.message.contains("turn on remote access")) + assertTrue(next.message.endsWith(SshFallback.NO_RELAY_ADVICE), next.message) + } + } + + /** + * Review of A49-anchor: a key none of the computer's reported keys name. Those keys are what + * nodeterm on the computer reads of its own SSH server, at every pairing and in every relay report, + * so neither is a way out when that server uses a key nodeterm cannot read: the advice must not + * promise one, and must name the relay, which is. + */ + @Test + fun `a key the computer never reported is sent to the relay, without the promise that pairing again trusts it`() { + val notReported = HostKeyNotPairedException(listOf("SHA256:reported"), "SHA256:a-key-nodeterm-cannot-read") + val auto = assertIs(SshFallback.afterFailure(notReported, relayAllowed = true, relayConfigured = true)) + val lanOnly = assertIs(SshFallback.afterFailure(notReported, relayAllowed = false, relayConfigured = true)) + val noRelay = listOf(true, false).map { + assertIs(SshFallback.afterFailure(notReported, relayAllowed = it, relayConfigured = false)).message + } + val withRelay = listOf(auto.error, assertNotNull(auto.warning), lanOnly.message) + for (t in withRelay + noRelay) { + assertTrue(t.contains(notReported.message!!), t) + // The changed-pin advice says pairing again (or the relay's report) trusts the key. Not here. + assertFalse(t.contains(SshFallback.REPAIR_NOTE), t) + assertFalse(t.contains(SshFallback.NO_RELAY_ADVICE), t) + assertFalse(t.contains("trusts the computer's current key"), t) + assertFalse(t.contains("trusts its new key"), t) + // When pairing again helps, and that otherwise SSH stays refused. + assertTrue(t.contains("only if"), t) + assertTrue(t.contains("changed since"), t) + assertTrue(t.contains("keeps refusing it on your network"), t) + } + // With a relay leg: the route that works, named as Settings names it. + for (t in withRelay) { + assertTrue(t.contains(relaySetting), t) + assertTrue(t.contains("reaches it only through the relay"), t) + } + // With none: how to get one, and not the relay-only setting, which would not help yet. + for (t in noRelay) { + assertTrue(t.contains("turn on remote access"), t) + assertTrue(t.contains("reaches it through the relay"), t) + assertFalse(t.contains(relaySetting), t) + } + } + + @Test + fun `re-pairing is offered as one way out, not as the only one`() { + val texts = listOf( + SshFallback.afterFailure(changed, relayAllowed = true, relayConfigured = true).let { it as Next.TryRelay } + .let { listOf(it.error, it.warning!!) }, + listOf((SshFallback.afterFailure(changed, relayAllowed = false, relayConfigured = true) as Next.Stop).message) + ).flatten() + for (t in texts) { + assertFalse(t.contains("remove and re-pair", ignoreCase = true), t) + assertTrue(t.contains(relaySetting), t) + } + // The bare exception names the likely causes before the alarming one. + val fact = changed.message!! + val causes = fact.indexOf("network address") + assertTrue(causes >= 0, fact) // indexOf answers -1 for a missing text, which is "earlier" + assertTrue(causes < fact.indexOf("intercepting"), fact) + } + + @Test + fun `an ordinary SSH failure falls through in Auto and stops on the SSH-only route, as before`() { + val down = HostException("Couldn't connect over SSH to dev@10.0.0.2:22 (timeout).") + assertEquals( + Next.TryRelay("On your network: Couldn't connect over SSH to dev@10.0.0.2:22 (timeout)."), + SshFallback.afterFailure(down, relayAllowed = true, relayConfigured = true) + ) + assertNull((SshFallback.afterFailure(down, relayAllowed = true, relayConfigured = false) as Next.TryRelay).warning) + assertEquals( + Next.Stop("On your network: Couldn't connect over SSH to dev@10.0.0.2:22 (timeout)."), + SshFallback.afterFailure(down, relayAllowed = false, relayConfigured = true) + ) + // No message at all still says what failed. + assertEquals( + Next.TryRelay("On your network: IllegalStateException"), + SshFallback.afterFailure(IllegalStateException(), relayAllowed = true, relayConfigured = true) + ) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshGitTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshGitTest.kt new file mode 100644 index 0000000000..f68bfb7e5a --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshGitTest.kt @@ -0,0 +1,226 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.git.GitReplies +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.HostUnansweredException +import dev.nodeterm.protocol.ssh.SshGit +import dev.nodeterm.protocol.ssh.SshGitScripts +import kotlinx.serialization.json.* +import java.io.File +import java.nio.file.Files +import kotlin.test.* + +/** Actual typed POSIX producer + public client parser against disposable real Git repositories. */ +class SshGitTest { + private fun fixture(block: (File, (List) -> String, SshGit) -> Unit) { + val root = Files.createTempDirectory("nt-ssh-git-").toFile() + val repo = File(root, "project ' quoted\n").apply { mkdirs() } + fun command(argv: List, dir: File = repo): Pair { + val p = ProcessBuilder(argv).directory(dir).redirectErrorStream(true).apply { + environment()["GIT_CONFIG_GLOBAL"] = "/dev/null" + environment()["GIT_CONFIG_SYSTEM"] = "/dev/null" + environment().keys.removeIf { it.startsWith("GIT_CONFIG_KEY_") || it.startsWith("GIT_CONFIG_VALUE_") } + environment().remove("GIT_CONFIG_COUNT") + listOf("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES").forEach(environment()::remove) + }.start() + val out = p.inputStream.bufferedReader().readText() + check(p.waitFor() == 0) { out } + return 0 to out + } + val git: (List) -> String = { command(listOf("git") + it).second } + git(listOf("init", "-b", "main")); git(listOf("config", "user.name", "SSH Git fixture")) + git(listOf("config", "user.email", "fixture@example.invalid")); git(listOf("config", "commit.gpgsign", "false")) + File(repo, "base").writeText("before\n") + git(listOf("add", "--", "base")); git(listOf("commit", "-m", "initial")) + val engine = SshGit(repo.path, listOf(repo.path)) { script, _ -> + // Keep actual nonzero exit statuses: a Git refusal is a response, not a transport loss. + val p = ProcessBuilder("/bin/sh", "-c", script).directory(root).redirectErrorStream(true).apply { + environment()["GIT_CONFIG_GLOBAL"] = "/dev/null"; environment()["GIT_CONFIG_SYSTEM"] = "/dev/null" + }.start() + val out = p.inputStream.bufferedReader().readText() + p.waitFor() to out + } + try { block(repo, git, engine) } finally { root.deleteRecursively() } + } + private fun paths(vararg paths: String) = mapOf("paths" to JsonArray(paths.map(::JsonPrimitive))) + private fun request(git: SshGit, verb: GitVerb, args: Map = emptyMap()) = git.request(verb, args) + + @Test fun `real Git status diff stage unstage commit and history retain literal hostile filenames`() = fixture { repo, shell, git -> + val names = listOf("space ' quote.txt", "-option", ":(glob)*", "two\nlines\tß.txt", "\$(touch INJECTION)") + names.forEach { File(repo, it).writeText("one\ntwo\n") } + val before = assertNotNull(GitReplies.status(request(git, GitVerb.STATUS))) + assertEquals(names.toSet(), before.untracked.map { it.path }.toSet()) + val diff = GitReplies.diff(request(git, GitVerb.DIFF, mapOf("path" to JsonPrimitive(names[0]), "untracked" to JsonPrimitive(true))))!! + assertTrue(diff.contains("+two")) + assertTrue(GitReplies.result(request(git, GitVerb.STAGE, paths(names[2])))!!.ok) + assertEquals(listOf(names[2]), GitReplies.status(request(git, GitVerb.STATUS))!!.staged.map { it.path }, "pathspec magic is a literal filename, never all paths") + assertEquals((names - names[2]).toSet(), GitReplies.status(request(git, GitVerb.STATUS))!!.untracked.map { it.path }.toSet()) + request(git, GitVerb.UNSTAGE, paths(names[2])) + assertTrue(GitReplies.result(request(git, GitVerb.STAGE, paths(*names.toTypedArray())))!!.ok) + val staged = GitReplies.status(request(git, GitVerb.STATUS))!! + assertEquals(names.toSet(), staged.staged.map { it.path }.toSet()) + assertTrue(staged.staged.all { it.added == 2 }) + assertTrue(GitReplies.result(request(git, GitVerb.UNSTAGE, paths(names[1])))!!.ok) + assertEquals(listOf(names[1]), GitReplies.status(request(git, GitVerb.STATUS))!!.untracked.map { it.path }) + request(git, GitVerb.STAGE, paths(names[1])) + val message = "literal ' \$(touch INJECTION)\nsecond line" + assertTrue(GitReplies.result(request(git, GitVerb.COMMIT, mapOf("message" to JsonPrimitive(message))))!!.ok) + val history = GitReplies.history(request(git, GitVerb.HISTORY))!! + assertEquals(2, history.commits.size) + assertEquals("literal ' \$(touch INJECTION) second line", history.commits.first().subject) + assertEquals("main", history.currentRef) + assertTrue(history.commits.first().timestampMs!! in 1_577_836_800_000L..(System.currentTimeMillis() + 1000L)) + assertTrue("main" in history.commits.first().refs) + assertFalse(File(repo, "INJECTION").exists()) + assertTrue(shell(listOf("log", "-1", "--format=%B")).contains(message)) + } + + @Test fun `real rename conflict and detached head map through the same client models`() = fixture { repo, shell, git -> + shell(listOf("mv", "base", "renamed\n'ß")) + val rename = GitReplies.status(request(git, GitVerb.STATUS))!! + assertEquals("renamed\n'ß", rename.staged.single().path) + assertEquals("R", rename.staged.single().status) + request(git, GitVerb.COMMIT, mapOf("message" to JsonPrimitive("rename"))) + shell(listOf("checkout", "-b", "other")); File(repo, "renamed\n'ß").writeText("other\n") + shell(listOf("commit", "-am", "other")); shell(listOf("checkout", "main")) + File(repo, "renamed\n'ß").writeText("main\n"); shell(listOf("commit", "-am", "main")) + assertFalse(GitReplies.result(request(git, GitVerb.PULL))!!.ok, "missing remote must be honest") + val merge = ProcessBuilder("git", "merge", "other").directory(repo).redirectErrorStream(true).start() + merge.inputStream.readBytes(); assertEquals(1, merge.waitFor()) + val conflict = GitReplies.status(request(git, GitVerb.STATUS))!! + assertEquals("UU", conflict.conflicts.single().code) + assertEquals("renamed\n'ß", conflict.conflicts.single().path) + assertTrue(conflict.staged.isEmpty() && conflict.changes.isEmpty()) + val diff = GitReplies.diff(request(git, GitVerb.DIFF, mapOf("path" to JsonPrimitive("renamed\n'ß"))))!! + assertTrue(diff.contains("<<<<<<<")) + shell(listOf("merge", "--abort")); shell(listOf("checkout", "--detach")) + assertEquals("HEAD", GitReplies.status(request(git, GitVerb.STATUS))!!.branch) + assertEquals(shell(listOf("rev-parse", "--short", "HEAD")).trim(), GitReplies.history(request(git, GitVerb.HISTORY))!!.currentRef) + } + + @Test fun `push sets a missing upstream only after confirmed refusal and pull keeps host policy`() = fixture { repo, shell, git -> + val remote = File(repo.parentFile, "remote.git") + val init = ProcessBuilder("git", "init", "--bare", "-b", "main", remote.path).redirectErrorStream(true).start() + init.inputStream.readBytes(); assertEquals(0, init.waitFor()) + shell(listOf("remote", "add", "origin", remote.path)) + assertTrue(GitReplies.status(request(git, GitVerb.STATUS))!!.hasRemote) + assertFalse(GitReplies.status(request(git, GitVerb.STATUS))!!.hasUpstream) + assertTrue(GitReplies.result(request(git, GitVerb.PUSH))!!.ok) + assertTrue(GitReplies.status(request(git, GitVerb.STATUS))!!.hasUpstream) + assertEquals("origin/main", GitReplies.history(request(git, GitVerb.HISTORY))!!.remoteRef) + File(repo, "base").appendText("outgoing\n"); shell(listOf("commit", "-am", "outgoing")) + assertTrue(GitReplies.history(request(git, GitVerb.HISTORY))!!.hasOutgoingChanges) + request(git, GitVerb.PUSH) + // A server's rejection text can mention the upstream advice too. It is not a refusal to + // dispatch, so the phone must not repeat this push (which could have partially succeeded). + File(repo, "base").appendText("rejected\n"); shell(listOf("commit", "-am", "rejected")) + val attempts = File(remote, "attempts") + val hook = File(remote, "hooks/pre-receive").apply { + writeText("#!/bin/sh\nprintf '%s\\n' attempt >> ${dev.nodeterm.protocol.ssh.SshScripts.q(attempts.path)}\nprintf '%s\\n' 'set-upstream: no upstream; rejected by fixture' >&2\nexit 1\n") + setExecutable(true) + } + val rejected = GitReplies.result(request(git, GitVerb.PUSH))!! + assertFalse(rejected.ok); assertTrue(rejected.message.contains("set-upstream")) + assertEquals(1, attempts.readLines().size, "a confirmed remote rejection is one push, despite matching upstream advice") + hook.delete(); shell(listOf("reset", "--hard", "HEAD~1")) + shell(listOf("reset", "--hard", "HEAD~1")) + assertTrue(GitReplies.history(request(git, GitVerb.HISTORY))!!.hasIncomingChanges) + assertTrue(GitReplies.result(request(git, GitVerb.PULL))!!.ok) + assertTrue(File(repo, "base").readText().contains("outgoing")) + shell(listOf("remote", "set-url", "origin", File(repo.parentFile, "missing.git").path)) + val failed = GitReplies.result(request(git, GitVerb.PUSH))!! + assertFalse(failed.ok); assertTrue(failed.message.contains("does not appear to be a git repository")) + } + + @Test fun `physical cwd jail rejects outside prefix traversal symlink and ancestor repository`() = fixture { repo, _, _ -> + val outside = File(repo.parentFile, repo.name + "-outside").apply { mkdirs() } + val link = File(repo, "escape"); Files.createSymbolicLink(link.toPath(), outside.toPath()) + fun run(cwd: File, roots: List = listOf(repo.path), rootMode: Boolean = false): Pair { + val p = ProcessBuilder("/bin/sh", "-c", SshGitScripts.command(cwd.path, roots, listOf("status", "--porcelain=v2", "-z"), rootMode)).start() + val out = p.inputStream.bufferedReader().readText(); return p.waitFor() to out + } + for (cwd in listOf(outside, link, File(repo, "../" + outside.name))) assertEquals(SshGitScripts.REFUSED, run(cwd).first, cwd.path) + val sub = File(repo, "sub").apply { mkdirs() } + assertEquals(0, run(sub, rootMode = true).first, "status operates at repo root for project descendants") + assertEquals(SshGitScripts.REFUSED, run(sub, listOf(sub.path), true).first, "a parent repo outside the listed project must not be staged") + assertEquals(0, run(sub, listOf("/"), true).first, "a genuine filesystem-root project is root-aware") + assertFailsWith { SshGitScripts.command(repo.path, emptyList(), listOf("status")) } + for (path in listOf("../base", "sub/../../base", "/base", "a\u0000b")) assertFailsWith { SshGitScripts.path(path) } + } + + @Test fun `unconfirmed writes never retry and returned signing errors remain Git results`() { + var calls = 0 + val git = SshGit("/project", listOf("/project")) { _, write -> if (!write) 0 to "main\n" else { calls++; null to "no upstream branch" } } + assertFailsWith { request(git, GitVerb.PUSH) } + assertEquals(1, calls) + var writes = 0 + val rejection = SshGit("/project", listOf("/project")) { _, write -> + if (!write) 0 to "main\n" else { writes++; 1 to "fatal: The current branch main has no upstream branch.\nset-upstream" } + } + assertFalse(GitReplies.result(request(rejection, GitVerb.PUSH))!!.ok) + assertEquals(1, writes, "exit1 cannot authorize a second write, even with exact native-looking prose") + for (out in listOf("remote: fatal: The current branch main has no upstream branch.", "fatal: The current branch changed has no upstream branch.")) { + writes = 0 + val other = SshGit("/project", listOf("/project")) { _, write -> + if (!write) 0 to "main\n" else { writes++; 128 to out } + } + assertFalse(GitReplies.result(request(other, GitVerb.PUSH))!!.ok) + assertEquals(1, writes, "only the exact native fatal for the captured branch permits an origin fallback") + } + val lost = SshGit("/project", listOf("/project")) { _, _ -> throw HostUnansweredException("lost acknowledgement") } + assertFailsWith { request(lost, GitVerb.COMMIT, mapOf("message" to JsonPrimitive("message"))) } + val failed = SshGit("/project", listOf("/project")) { _, _ -> 1 to "error: gpg failed to sign the data" } + assertEquals("error: gpg failed to sign the data", GitReplies.result(request(failed, GitVerb.COMMIT, mapOf("message" to JsonPrimitive("message"))))!!.message) + } + + @Test fun `missing Git and inherited repository environment cannot impersonate a project repository`() = fixture { repo, shell, _ -> + val ordinary = File(repo.parentFile, "ordinary").apply { mkdirs() } + val foreign = File(repo.parentFile, "foreign") + shell(listOf("init", "-b", "foreign", foreign.path)) + fun engine(cwd: File, env: Map) = SshGit(cwd.path, listOf(cwd.path)) { script, _ -> + val p = ProcessBuilder("/bin/sh", "-c", script).apply { environment().putAll(env) }.start() + val out = p.inputStream.bufferedReader().readText(); p.waitFor() to out + } + assertFalse(GitReplies.status(request(engine(ordinary, mapOf("GIT_DIR" to File(repo, ".git").path, "GIT_WORK_TREE" to repo.path)), GitVerb.STATUS))!!.hasRepo) + assertEquals("main", GitReplies.status(request(engine(repo, mapOf("GIT_DIR" to File(foreign, ".git").path, "GIT_WORK_TREE" to repo.path)), GitVerb.STATUS))!!.branch, + "a listed cwd must not silently use an inherited foreign Git index/branch") + val missing = assertFailsWith { request(engine(repo, mapOf("PATH" to "/no-such-git-bin")), GitVerb.STATUS) } + assertTrue(missing.message!!.contains("Git is not installed"), missing.message) + } + + @Test fun `ordinary folder unborn history and history pagination are explicit`() = fixture { repo, shell, git -> + val folder = File(repo.parentFile, "ordinary").apply { mkdirs() } + val empty = SshGit(folder.path, listOf(folder.path)) { script, _ -> + val p = ProcessBuilder("/bin/sh", "-c", script).start(); val out = p.inputStream.bufferedReader().readText(); p.waitFor() to out + } + assertFalse(GitReplies.status(request(empty, GitVerb.STATUS))!!.hasRepo) + shell(listOf("checkout", "--orphan", "unborn")) + assertTrue(GitReplies.history(request(git, GitVerb.HISTORY))!!.commits.isEmpty()) + shell(listOf("checkout", "main")) + for (n in 1..51) shell(listOf("commit", "--allow-empty", "-m", "page-$n")) + val history = GitReplies.history(request(git, GitVerb.HISTORY))!! + assertEquals(50, history.commits.size); assertTrue(history.hasMore) + assertEquals("page-51", history.commits.first().subject) + } + + @Test fun `history does not execute signature verification or mix configured GPG prose into typed records`() = fixture { repo, shell, git -> + val raw = shell(listOf("cat-file", "-p", "HEAD")) + val signature = "gpgsig -----BEGIN PGP SIGNATURE-----\n fixture\n -----END PGP SIGNATURE-----\n" + val commit = raw.substringBefore("\n\n") + "\n" + signature + "\n" + raw.substringAfter("\n\n") + val hash = ProcessBuilder("git", "hash-object", "-t", "commit", "-w", "--stdin").directory(repo).start() + hash.outputStream.use { it.write(commit.toByteArray()) } + val id = hash.inputStream.bufferedReader().readText().trim() + assertEquals(0, hash.waitFor()) + shell(listOf("update-ref", "refs/heads/main", id)) + val marker = File(repo, "verification-was-executed") + val verifier = File(repo.parentFile, "fixture-verifier").apply { + writeText("#!/bin/sh\nprintf called > ${dev.nodeterm.protocol.ssh.SshScripts.q(marker.path)}\nprintf 'fixture verifier prose\\n' >&2\nexit 1\n") + setExecutable(true) + } + shell(listOf("config", "gpg.program", verifier.path)) + shell(listOf("config", "log.showSignature", "true")) + assertEquals(id, GitReplies.history(request(git, GitVerb.HISTORY))!!.commits.first().id) + assertFalse(marker.exists(), "history has a typed format, not an interactive GPG verification") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshHistoryTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshHistoryTest.kt new file mode 100644 index 0000000000..fbba7a273c --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshHistoryTest.kt @@ -0,0 +1,73 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.TerminalHistory as H +import dev.nodeterm.protocol.ssh.SshScripts +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.nio.file.Files +import kotlin.test.* + +class SshHistoryTest { + private fun check(history: String, query: String, failure: Boolean = false): Pair { + assumeTrue(File("/bin/sh").canExecute()) + val home = Files.createTempDirectory("nt-history-").toFile() + try { + val bin = File(home, "bin").apply { mkdirs() } + File(home, "history").writeText(history) + File(bin, "tmux").apply { + writeText(""" + #!/bin/sh + [ "${'$'}1" = -L ] && [ "${'$'}2" = nodeterm-rmt ] || exit 9 + case "${'$'}3" in + display-message) + [ "${'$'}6" = '=nt-search:' ] && [ "${'$'}7" = '#{pane_id}' ] || exit 8 + printf '%%42\n';; + capture-pane) + [ "${'$'}4" = -p ] && [ "${'$'}5" = -J ] && [ "${'$'}6" = -t ] && [ "${'$'}7" = %42 ] && [ "${'$'}8" = -S ] && [ "${'$'}9" = - ] || exit 7 + ${if (failure) "exit 6" else "cat \"${'$'}HOME/history\""};; + *) exit 5;; + esac + """.trimIndent()) + setExecutable(true) + } + val pb = ProcessBuilder("/bin/sh", "-c", SshScripts.searchHistory("search", query, "nodeterm-rmt")) + pb.environment().clear() + pb.environment().putAll(mapOf("HOME" to home.path, "PATH" to "${bin.path}:/usr/bin:/bin", "TMPDIR" to home.path)) + val p = pb.start() + val out = p.inputStream.bufferedReader().readText() + val code = p.waitFor() + assertFalse(File(home, "injected").exists()) + assertTrue(home.listFiles()!!.none { it.name.startsWith("nodeterm-history.") }, "private spool removed") + return code to out + } finally { home.deleteRecursively() } + } + @Test fun `real shell awk scans old output and treats query as literal bytes`() { + val query = "'\"`$(touch injected).* Ω 😀\\x" + val (code, out) = check("old$query\n" + "recent\n".repeat(500), query) + assertEquals(0, code) + assertEquals(H.Result(501, false, listOf(H.Row(0, "old$query"))), H.parseSsh(out, query)) + } + @Test fun `bounded results still count the complete history`() { + val (code, out) = check("match\n".repeat(300) + "tail\n", "match") + assertEquals(0, code) + val result = H.parseSsh(out, "match")!! + assertTrue(result.truncated) + assertEquals(301, result.searchedLines) + assertEquals(200, result.rows.size) + } + @Test fun `regular expression syntax cannot match unrelated output`() { + val (code, out) = check("unrelated Ω 😀\n", ".* Ω 😀") + assertEquals(0, code) + assertEquals(H.Result(1, false, emptyList()), H.parseSsh(out, ".* Ω 😀")) + } + @Test fun `capture failure produces an error instead of successful empty results`() { + val (code, out) = check("match\n", "match", failure = true) + assertNotEquals(0, code) + assertNull(H.parseSsh(out, "match")) + } + @Test fun `unlisted sockets and invalid queries refuse and node names are normalized`() { + assertFailsWith { SshScripts.searchHistory("search", "x", "foreign") } + assertTrue(SshScripts.searchHistory("search;evil", "x", "node-terminal").contains("'=nt-search_evil:'")) + assertFailsWith { SshScripts.searchHistory("search", "x\ny", "node-terminal") } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshPasswordBootstrapTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshPasswordBootstrapTest.kt new file mode 100644 index 0000000000..8611f680c9 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshPasswordBootstrapTest.kt @@ -0,0 +1,336 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.HostUnansweredException +import dev.nodeterm.protocol.pairing.SshIdentity +import dev.nodeterm.protocol.ssh.HostKeyChangedException +import dev.nodeterm.protocol.ssh.ManualHost +import dev.nodeterm.protocol.ssh.SshHostConnection +import dev.nodeterm.protocol.ssh.SshPasswordBootstrap +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.apache.sshd.common.keyprovider.KeyPairProvider +import org.apache.sshd.server.Environment +import org.apache.sshd.server.ExitCallback +import org.apache.sshd.server.SshServer +import org.apache.sshd.server.auth.keyboard.InteractiveChallenge +import org.apache.sshd.server.auth.keyboard.KeyboardInteractiveAuthenticator +import org.apache.sshd.server.auth.password.PasswordAuthenticator +import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator +import org.apache.sshd.server.channel.ChannelSession +import org.apache.sshd.server.command.Command +import org.apache.sshd.server.command.CommandFactory +import org.apache.sshd.server.config.keys.AuthorizedKeysAuthenticator +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider +import org.apache.sshd.server.session.ServerSession +import java.io.Closeable +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.Socket +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import java.security.PublicKey +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import javax.net.SocketFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** Real MINA authentication and real /bin/sh installation in a fresh private HOME, never user files. */ +class SshPasswordBootstrapTest { + private val identity = SshIdentity.fromSeed(ByteArray(32) { (it + 9).toByte() }) + + private class Fixture : Closeable { + val root = Files.createTempDirectory("nt-ssh-enroll-").toFile() + val home = File(root, "home").apply { mkdir() } + val keys get() = File(home, ".ssh/authorized_keys") + val passwordCalls = AtomicInteger() + val keyCalls = AtomicInteger() + val interactiveCalls = AtomicInteger() + val commandCalls = AtomicInteger() + val commandStarted = CompletableDeferred() + val keyStarted = CompletableDeferred() + val authStarted = CompletableDeferred() + var omitExit = false + var rejectKey = false + var replaceHostKey = false + var authDelayMs = 0L + var commandDelayMs = 0L + var keyDelayMs = 0L + var stderrBytes = 0 + val sessions = CopyOnWriteArrayList() + private val first = SimpleGeneratorHostKeyProvider(File(root, "host.ser").toPath()) + private val other = SimpleGeneratorHostKeyProvider(File(root, "other-host.ser").toPath()) + private var provider: KeyPairProvider = first + val sshd = SshServer.setUpDefaultServer().apply { + host = "127.0.0.1" + port = 0 + keyPairProvider = KeyPairProvider { session -> provider.loadKeys(session) } + passwordAuthenticator = PasswordAuthenticator { user, password, session -> + sessions += session + passwordCalls.incrementAndGet() + authStarted.complete(Unit) + if (authDelayMs > 0) Thread.sleep(authDelayMs) + user == "dev" && password == "fixture-password" + } + val actual = object : AuthorizedKeysAuthenticator(File(home, ".ssh/authorized_keys").toPath()) { + override fun isValidUsername(username: String?, session: ServerSession?) = username == "dev" + } + publickeyAuthenticator = PublickeyAuthenticator { user, key, session -> + keyCalls.incrementAndGet() + keyStarted.complete(Unit) + if (keyDelayMs > 0) Thread.sleep(keyDelayMs) + !rejectKey && actual.authenticate(user, key, session) + } + keyboardInteractiveAuthenticator = object : KeyboardInteractiveAuthenticator { + override fun generateChallenge(session: ServerSession, username: String, lang: String, subMethods: String): InteractiveChallenge { + interactiveCalls.incrementAndGet() + return InteractiveChallenge().apply { addPrompt("Fixture MFA", false) } + } + override fun authenticate(session: ServerSession, username: String, responses: MutableList): Boolean = true + } + commandFactory = CommandFactory { _, command -> Shell(command) } + try { start() } catch (e: Exception) { root.deleteRecursively(); throw e } + } + val address get() = ManualHost.Address("127.0.0.1", sshd.port, "dev", "Enrollment fixture") + fun fingerprint(): String = SshHostConnection.hostKeyFingerprint(provider.loadKeys(null).first().public) + + private inner class Shell(private val command: String) : Command { + private lateinit var output: OutputStream + private lateinit var error: OutputStream + private lateinit var exit: ExitCallback + private var process: Process? = null + private var worker: Thread? = null + override fun setInputStream(input: InputStream) = Unit + override fun setOutputStream(out: OutputStream) { output = out } + override fun setErrorStream(err: OutputStream) { error = err } + override fun setExitCallback(callback: ExitCallback) { exit = callback } + override fun start(channel: ChannelSession, env: Environment) { + commandCalls.incrementAndGet() + commandStarted.complete(Unit) + worker = Thread { + try { + if (commandDelayMs > 0) Thread.sleep(commandDelayMs) + if (stderrBytes > 0) { error.write(ByteArray(stderrBytes) { 120 }); error.flush() } + val pb = ProcessBuilder("/bin/sh", "-c", command).directory(home) + pb.environment().clear() + pb.environment().putAll(mapOf("HOME" to home.path, "PATH" to "/usr/bin:/bin")) + val p = pb.start() + process = p + p.outputStream.close() + val errReader = Thread { runCatching { p.errorStream.copyTo(error) } }.apply { isDaemon = true; start() } + p.inputStream.copyTo(output) + val code = p.waitFor() + errReader.join(2000) + if (replaceHostKey) provider = other + if (omitExit) channel.close(false) else exit.onExit(code) + } catch (_: InterruptedException) { + channel.close(true) + } finally { + process?.destroyForcibly() + } + }.apply { isDaemon = true; start() } + } + override fun destroy(channel: ChannelSession) { worker?.interrupt(); process?.destroyForcibly() } + } + override fun close() { + sshd.stop(true) + root.deleteRecursively() + } + } + + private suspend fun Fixture.confirm() = SshPasswordBootstrap.inspect(address).let { it.confirm(it.fingerprint) } + private fun password() = "fixture-password".toCharArray() + + @Test fun `inspection sends no authentication and confirmation must match its exact fingerprint`() = runBlocking { + Fixture().use { host -> + val inspection = SshPasswordBootstrap.inspect(host.address) + assertEquals(host.fingerprint(), inspection.fingerprint) + assertEquals(host.address, inspection.address) + assertEquals(0, host.passwordCalls.get()) + assertEquals(0, host.keyCalls.get()) + assertEquals(0, host.interactiveCalls.get()) + assertEquals(0, host.commandCalls.get()) + assertFalse(host.keys.exists()) + assertFailsWith { inspection.confirm("SHA256:" + "A".repeat(43)) } + } + } + + @Test fun `enrollment preserves other keys, installs only retained public identity and proves a fresh pinned key login`() = runBlocking { + Fixture().use { host -> + host.keys.parentFile.mkdir() + val otherKey = SshIdentity.generate().authorizedKeysLine("fixture-other") + host.keys.writeText(otherKey) // No final LF: it must not concatenate keys. + val secret = password() + val record = SshPasswordBootstrap.install(host.confirm(), identity, secret, id = "ssh-fixture", now = 77) + assertTrue(secret.all { it == '\u0000' }) + assertEquals("$otherKey\n${ManualHost.authorizedKeysLine(identity)}\n", host.keys.readText()) + assertEquals("rwx------", PosixFilePermissions.toString(Files.getPosixFilePermissions(host.keys.parentFile.toPath()))) + assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(host.keys.toPath()))) + assertEquals(ManualHost.record(host.address, host.fingerprint(), "ssh-fixture", 77), record) + assertEquals(1, host.passwordCalls.get()) + assertTrue(host.keyCalls.get() > 0, "Success requires an independent retained-key login") + assertEquals(1, host.commandCalls.get()) + assertEquals(0, host.interactiveCalls.get()) + assertTrue(record.manual && record.relay == null) + // A deliberately fresh user-confirmed attempt is safe and does not duplicate a key. + SshPasswordBootstrap.install(host.confirm(), identity, password()) + assertEquals("$otherKey\n${ManualHost.authorizedKeysLine(identity)}\n", host.keys.readText()) + } + } + + @Test fun `the same retained public key with another comment is not appended again`() = runBlocking { + Fixture().use { host -> + host.keys.parentFile.mkdir() + val retained = identity.authorizedKeysLine("existing-device-comment") + "\n" + host.keys.writeText(retained) + SshPasswordBootstrap.install(host.confirm(), identity, password()) + assertEquals(retained, host.keys.readText()) + } + } + + @Test fun `a changed host is refused before the password is offered`() = runBlocking { + Fixture().use { host -> + val wrong = SshPasswordBootstrap.Inspection(host.address, "SHA256:" + "A".repeat(43)).confirm("SHA256:" + "A".repeat(43)) + val secret = password() + assertFailsWith { SshPasswordBootstrap.install(wrong, identity, secret) } + assertTrue(secret.all { it == '\u0000' }) + assertEquals(0, host.passwordCalls.get()) + assertEquals(0, host.keyCalls.get()) + assertEquals(0, host.interactiveCalls.get()) + assertFalse(host.keys.exists()) + } + } + + @Test fun `refused password does not fall back to keyboard interactive or install a key`() = runBlocking { + Fixture().use { host -> + val secret = "wrong-private-password".toCharArray() + val e = assertFailsWith { SshPasswordBootstrap.install(host.confirm(), identity, secret) } + assertFalse(e.message!!.contains("wrong-private-password")) + assertTrue(e.message!!.contains("keyboard-interactive")) + assertEquals(1, host.passwordCalls.get()) + assertEquals(0, host.interactiveCalls.get(), "This accepting MFA boundary must never be attempted") + assertEquals(0, host.commandCalls.get()) + assertFalse(host.keys.exists()) + assertTrue(secret.all { it == '\u0000' }) + } + } + + @Test fun `a confirmed write without exit acknowledgement is uncertain and cannot be replayed`() = runBlocking { + Fixture().use { host -> + host.omitExit = true + val confirmed = host.confirm() + val e = assertFailsWith { SshPasswordBootstrap.install(confirmed, identity, password()) } + assertTrue(e.message!!.contains("may already be installed")) + assertEquals(ManualHost.authorizedKeysLine(identity) + "\n", host.keys.readText()) + assertEquals(0, host.keyCalls.get()) + val secret = password() + assertFailsWith { SshPasswordBootstrap.install(confirmed, identity, secret) } + assertTrue(secret.all { it == '\u0000' }) + assertEquals(1, host.commandCalls.get()) + assertEquals(1, host.passwordCalls.get()) + } + } + + @Test fun `successful installation alone cannot return a record when retained-key login is refused`() = runBlocking { + Fixture().use { host -> + host.rejectKey = true + val e = assertFailsWith { SshPasswordBootstrap.install(host.confirm(), identity, password()) } + assertTrue(e.message!!.contains("no computer was saved")) + assertTrue(host.keyCalls.get() > 0) + assertEquals(1, host.commandCalls.get()) + assertEquals(ManualHost.authorizedKeysLine(identity) + "\n", host.keys.readText()) + } + } + + @Test fun `retained-key verification must present the original confirmed host fingerprint`() = runBlocking { + Fixture().use { host -> + val original = host.fingerprint() + host.replaceHostKey = true + assertFailsWith { SshPasswordBootstrap.install(host.confirm(), identity, password()) } + assertNotEquals(original, host.fingerprint()) + assertEquals(1, host.passwordCalls.get()) + assertEquals(0, host.keyCalls.get(), "No retained key is offered to the changed host") + } + } + + @Test fun `own command timeout is uncertain, external cancellation propagates, both close owned sockets`() = runBlocking { + Fixture().use { host -> + host.commandDelayMs = 15_000 + val confirmed = host.confirm() + val sockets = RecordingSockets() + val attempt = async { runCatching { SshPasswordBootstrap.install(confirmed, identity, password(), timeoutMs = 3000, socketFactory = sockets) } } + withTimeout(5000) { host.commandStarted.await() } + val e = assertFailsWith { attempt.await().getOrThrow() } + assertTrue(e.message!!.contains("will not be repeated")) + assertTrue(sockets.opened.isNotEmpty() && sockets.opened.all { it.isClosed }) + assertFalse(host.keys.exists()) + assertEquals(1, host.commandCalls.get()) + assertFailsWith { SshPasswordBootstrap.install(confirmed, identity, password()) } + } + Fixture().use { host -> + host.commandDelayMs = 15_000 + val confirmed = host.confirm() + val sockets = RecordingSockets() + val attempt = async { runCatching { + coroutineScope { + val enrollment = async { SshPasswordBootstrap.install(confirmed, identity, password(), socketFactory = sockets) } + withTimeout(5000) { host.commandStarted.await() } + withTimeout(100) { enrollment.await() } + } + } } + assertFailsWith { attempt.await().getOrThrow() } + assertTrue(sockets.opened.isNotEmpty() && sockets.opened.all { it.isClosed }) + assertFalse(host.keys.exists()) + } + } + + @Test fun `cancellation during password or key verification closes resources and returns no record`() = runBlocking { + for (duringKey in listOf(false, true)) Fixture().use { host -> + if (duringKey) host.keyDelayMs = 15_000 else host.authDelayMs = 15_000 + val confirmed = host.confirm() + val sockets = RecordingSockets() + val secret = password() + val job = async { SshPasswordBootstrap.install(confirmed, identity, secret, socketFactory = sockets) } + withTimeout(5000) { if (duringKey) host.keyStarted.await() else host.authStarted.await() } + job.cancelAndJoin() + assertTrue(secret.all { it == '\u0000' }) + assertTrue(sockets.opened.isNotEmpty() && sockets.opened.all { it.isClosed }) + assertEquals(if (duringKey) 1 else 0, host.commandCalls.get()) + assertEquals(duringKey, host.keys.exists()) + } + } + + @Test fun `both command streams are bounded and server stderr never becomes a surfaced error`() = runBlocking { + Fixture().use { host -> + host.stderrBytes = 16_384 + val e = assertFailsWith { SshPasswordBootstrap.install(host.confirm(), identity, password(), timeoutMs = 5000) } + assertFalse(e.message!!.contains("x".repeat(32))) + assertEquals(1, host.commandCalls.get()) + assertEquals(0, host.keyCalls.get()) + } + } + + private class RecordingSockets : SocketFactory() { + val opened = CopyOnWriteArrayList() + private fun record(socket: Socket): Socket = socket.also { opened += it } + override fun createSocket() = record(Socket()) + override fun createSocket(host: String, port: Int) = record(Socket(host, port)) + override fun createSocket(host: String, port: Int, local: InetAddress, localPort: Int) = record(Socket(host, port, local, localPort)) + override fun createSocket(host: InetAddress, port: Int) = record(Socket(host, port)) + override fun createSocket(host: InetAddress, port: Int, local: InetAddress, localPort: Int) = record(Socket(host, port, local, localPort)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshPasswordInstallTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshPasswordInstallTest.kt new file mode 100644 index 0000000000..fdcac92120 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshPasswordInstallTest.kt @@ -0,0 +1,164 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.pairing.SshIdentity +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.ssh.ManualHost +import dev.nodeterm.protocol.ssh.SshPasswordBootstrap +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import java.io.File +import java.net.InetAddress +import java.net.Socket +import java.net.SocketAddress +import java.net.SocketException +import java.nio.file.Files +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import javax.net.SocketFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Actual POSIX command, private synthetic HOME only; never a live login or key store. */ +class SshPasswordInstallTest { + private val identity = SshIdentity.fromSeed(ByteArray(32) { (it + 6).toByte() }) + + private fun run(home: File, path: String = "/usr/bin:/bin"): Int { + val pb = ProcessBuilder("/bin/sh", "-c", SshPasswordBootstrap.installCommand(identity)).redirectErrorStream(true) + pb.environment().clear() + pb.environment().putAll(mapOf("HOME" to home.path, "PATH" to path)) + val p = pb.start() + assertEquals(true, p.waitFor(5, TimeUnit.SECONDS), "Command must refuse unsafe nonregular files without waiting on them") + p.inputStream.readBytes() + return p.exitValue() + } + + @Test fun `automatic install refuses linked directory and key file without changing their targets`() { + val root = Files.createTempDirectory("nt-enroll-unsafe-").toFile() + try { + val home = File(root, "home").apply { mkdir() } + val outside = File(root, "outside").apply { mkdir() } + val keys = File(outside, "authorized_keys").apply { writeText("sentinel-key\n") } + Files.createSymbolicLink(File(home, ".ssh").toPath(), outside.toPath()) + assertEquals(2, run(home)) + assertEquals("sentinel-key\n", keys.readText()) + File(home, ".ssh").delete() + File(home, ".ssh").mkdir() + Files.createSymbolicLink(File(home, ".ssh/authorized_keys").toPath(), keys.toPath()) + assertEquals(2, run(home)) + assertEquals("sentinel-key\n", keys.readText()) + } finally { root.deleteRecursively() } + } + + @Test fun `automatic install refuses nonregular and multiply-linked key files`() { + val root = Files.createTempDirectory("nt-enroll-type-").toFile() + try { + val ssh = File(root, ".ssh").apply { mkdir() } + val keys = File(ssh, "authorized_keys").apply { mkdir() } + assertEquals(2, run(root)) + keys.delete() + val other = File(root, "other").apply { writeText("hardlink-sentinel\n") } + Files.createLink(keys.toPath(), other.toPath()) + assertEquals(2, run(root)) + assertEquals("hardlink-sentinel\n", other.readText()) + keys.delete() + val mkfifo = ProcessBuilder("mkfifo", keys.path).start() + assertEquals(0, mkfifo.waitFor()) + assertEquals(2, run(root)) + } finally { root.deleteRecursively() } + } + + @Test fun `automatic install refuses foreign owner metadata before altering permissions or key contents`() { + val root = Files.createTempDirectory("nt-enroll-owner-").toFile() + try { + val ssh = File(root, ".ssh").apply { mkdir() } + val keys = File(ssh, "authorized_keys").apply { writeText("owner-sentinel\n") } + val bin = File(root, "bin").apply { mkdir() } + // Inert stat OS boundary: actual files stay owned by the test account. No chown privilege + // or another user's files are needed to exercise foreign-owner refusal. + File(bin, "stat").apply { writeText("#!/bin/sh\nprintf '999999\\n'\n"); setExecutable(true) } + assertEquals(2, run(root, bin.path + ":/usr/bin:/bin")) + assertEquals("owner-sentinel\n", keys.readText()) + assertFalse(keys.readText().contains("nodeterm-android")) + } finally { root.deleteRecursively() } + } + + @Test fun `comments and another keys trailing comment do not suppress retained-key installation`() { + for (prefix in listOf("# " + identity.authorizedKeysLine("comment-only"), + SshIdentity.generate().authorizedKeysLine("other-key") + " " + identity.authorizedKeysLine("trailing-note"))) { + val root = Files.createTempDirectory("nt-enroll-comment-").toFile() + try { + val keys = File(root, ".ssh").apply { mkdir() }.resolve("authorized_keys") + keys.writeText(prefix + "\n") + assertEquals(0, run(root)) + assertEquals(prefix + "\n" + identity.authorizedKeysLine() + "\n", keys.readText()) + } finally { root.deleteRecursively() } + } + } + + @Test fun `an existing restricted key with quoted and escaped options stays restricted without a duplicate`() { + val root = Files.createTempDirectory("nt-enroll-options-").toFile() + try { + val keys = File(root, ".ssh").apply { mkdir() }.resolve("authorized_keys") + val existing = "command=\"printf \\\"fixture only\\\"\",restrict " + identity.authorizedKeysLine("restricted") + "\n" + keys.writeText(existing) + assertEquals(0, run(root)) + assertEquals(existing, keys.readText(), "Never append unrestricted authorization around a retained key's restrictions") + keys.writeText("command=\"unterminated " + identity.authorizedKeysLine() + "\n") + assertEquals(2, run(root), "Ambiguous options must refuse without modifying authorization") + assertEquals("command=\"unterminated " + identity.authorizedKeysLine() + "\n", keys.readText()) + } finally { root.deleteRecursively() } + } + + @Test fun `own inspection deadline is a visible error but outer cancellation propagates and both close the dial socket`() = runBlocking { + val address = ManualHost.Address("127.0.0.1", 1, "fixture", "Fixture") + val own = BlockingSockets() + // Cold SSHJ/provider initialization is not socket admission. Give it a realistic budget, + // and prove this attempt actually entered the blocking dial before checking cleanup. + val ownAttempt = async { runCatching { SshPasswordBootstrap.inspect(address, 3000, own) } } + withTimeout(5000) { own.admitted.await() } + val e = assertFailsWith { ownAttempt.await().getOrThrow() } + assertTrue(e.message!!.contains("timed out")) + assertTrue(own.opened.isNotEmpty() && own.opened.all { it.isClosed }) + val outer = BlockingSockets() + val outerAttempt = async { runCatching { + coroutineScope { + val inspection = async { SshPasswordBootstrap.inspect(address, 10_000, outer) } + withTimeout(5000) { outer.admitted.await() } + // Start the external wait's deadline after admission. Its failure cancels the + // enclosing scope and therefore the actual in-flight inspection child. + withTimeout(100) { inspection.await() } + } + } } + assertFailsWith { outerAttempt.await().getOrThrow() } + assertTrue(outer.opened.isNotEmpty() && outer.opened.all { it.isClosed }) + } + + /** Inert socket boundary, modeling a blocking dial that ignores thread interruption. */ + private class BlockingSockets : SocketFactory() { + val opened = CopyOnWriteArrayList() + val admitted = CompletableDeferred() + override fun createSocket(): Socket = object : Socket() { + private val closed = CountDownLatch(1) + override fun connect(endpoint: SocketAddress, timeout: Int) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (closed.count != 0L && System.nanoTime() < deadline) { + try { closed.await(10, TimeUnit.MILLISECONDS) } catch (_: InterruptedException) { /* close owns cancellation */ } + } + throw SocketException("fixture closed dial") + } + override fun close() { super.close(); closed.countDown() } + }.also { opened += it; admitted.complete(Unit) } + override fun createSocket(host: String, port: Int): Socket = error("unused socket overload") + override fun createSocket(host: String, port: Int, local: InetAddress, localPort: Int): Socket = error("unused socket overload") + override fun createSocket(host: InetAddress, port: Int): Socket = error("unused socket overload") + override fun createSocket(host: InetAddress, port: Int, local: InetAddress, localPort: Int): Socket = error("unused socket overload") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshProfilePathTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshProfilePathTest.kt new file mode 100644 index 0000000000..5dd645f574 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshProfilePathTest.kt @@ -0,0 +1,56 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.ssh.SshProfilePath +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** A119: choosing a profile is explicit authority; validation never repairs it into another one. */ +class SshProfilePathTest { + @Test fun `blank form input restores automatic discovery and trailing separators normalize`() { + assertNull(SshProfilePath.fromInput("")) + assertNull(SshProfilePath.fromInput(" \t ")) + SshProfilePath.requireValid(null) + assertEquals("/profiles/a", SshProfilePath.fromInput("/profiles/a///")) + assertEquals("/", SshProfilePath.fromInput("////")) + } + + @Test fun `absolute paths preserve spaces quotes dollar signs and unicode literally`() { + val path = "/profiles/é space's ${'$'}HOME" + assertEquals(path, SshProfilePath.fromInput(path)) + assertNull(SshProfilePath.error(path)) + assertEquals("/profiles/with trailing space ", SshProfilePath.fromInput("/profiles/with trailing space ")) + } + + @Test fun `relative home expansion and dot traversal components are refused`() { + for (path in listOf("profiles/a", "~/profile", "/a/./b", "/a/../b", "/.", "/..")) { + assertNotNull(SshProfilePath.error(path), path) + assertFailsWith(path) { SshProfilePath.fromInput(path) } + assertFailsWith(path) { SshProfilePath.requireValid(path) } + } + } + + @Test fun `every C0 DEL and C1 control is rejected before any use`() { + for (code in (0..31) + (127..159)) { + val path = "/profiles/a" + code.toChar() + assertNotNull(SshProfilePath.error(path), "Control $code must refuse") + assertFailsWith { SshProfilePath.fromInput(path) } + assertFailsWith { SshProfilePath.requireValid(path) } + } + } + + @Test fun `a maximum length absolute path is accepted and an extra character refused`() { + val path = "/" + "a".repeat(SshProfilePath.MAX_LENGTH - 1) + assertEquals(path, SshProfilePath.fromInput(path)) + assertNull(SshProfilePath.error(path)) + assertFailsWith { SshProfilePath.requireValid(path + "b") } + } + + @Test fun `invalid saved strings refuse instead of being interpreted as blank form input`() { + for (path in listOf("", " ", "relative", "/profiles/../other")) { + assertFailsWith(path) { SshProfilePath.requireValid(path) } + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshScriptsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshScriptsTest.kt new file mode 100644 index 0000000000..3d0331e0d5 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshScriptsTest.kt @@ -0,0 +1,186 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.ssh.HostBrowse +import dev.nodeterm.protocol.ssh.SshScripts +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The generated shell run for real under `/bin/sh` against fake HOMEs laid out the way a desktop + * lays them out — no SSH involved, so each case can have its own HOME. + * + * The browse runs whatever tmux it finds, read-only (`list-sessions`). Each run gets its own + * `TMUX_TMPDIR` inside its HOME, so even that never reaches a tmux server a developer is using + * (CLAUDE.md, "The test suite never touches a live tmux server"). + */ +class SshScriptsTest { + private fun sh(script: String, home: File, extraEnv: Map = emptyMap()): String { + val pb = ProcessBuilder("/bin/sh", "-c", script).redirectErrorStream(true) + pb.environment().clear() + val tmuxDir = File(home, ".tmux-sandbox").apply { mkdirs() } + pb.environment().putAll(mapOf("HOME" to home.path, "PATH" to "/usr/bin:/bin", "TMUX_TMPDIR" to tmuxDir.path) + extraEnv) + val p = pb.start() + val out = p.inputStream.bufferedReader().readText() + p.waitFor() + return out + } + + private fun udOf(home: File, env: Map = emptyMap()): String = + sh(SshScripts.browse(), home, env).lineSequence().first { it.startsWith("ud=") }.removePrefix("ud=") + + private fun freshHome(): File = Files.createTempDirectory("nt-home").toFile() + + @Test + fun `the desktop's real userData dir (node-terminal) is found on Linux and macOS layouts`() { + assumeTrue(File("/bin/sh").canExecute()) + // A02: the desktop's userData is named after package.json `name` = node-terminal. + val linux = freshHome() + File(linux, ".config/node-terminal").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(linux, ".config/node-terminal").path, udOf(linux)) + + val mac = freshHome() + File(mac, "Library/Application Support/node-terminal").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(mac, "Library/Application Support/node-terminal").path, udOf(mac)) + + val xdg = freshHome() + val cfg = File(xdg, "cfg") + File(cfg, "node-terminal").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(cfg, "node-terminal").path, udOf(xdg, mapOf("XDG_CONFIG_HOME" to cfg.path))) + listOf(linux, mac, xdg).forEach { it.deleteRecursively() } + } + + @Test + fun `the legacy nodeterm spelling is a fallback, never preferred`() { + assumeTrue(File("/bin/sh").canExecute()) + val legacy = freshHome() + File(legacy, ".config/nodeterm").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(legacy, ".config/nodeterm").path, udOf(legacy)) + + val both = freshHome() + File(both, ".config/nodeterm").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + File(both, ".config/node-terminal").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(both, ".config/node-terminal").path, udOf(both)) + + val none = freshHome() + assertEquals("", udOf(none)) + listOf(legacy, both, none).forEach { it.deleteRecursively() } + } + + // ---- Audit A27: the Server Edition's data dir, and what a driving desktop leaves here ---------- + + @Test + fun `the Server Edition's data dir is found after the desktop's, by any file it writes, and NODETERM_DATA_DIR first`() { + assumeTrue(File("/bin/sh").canExecute()) + // Default dir, with a workspace; and a fresh install that has written only its install metadata. + val server = freshHome() + File(server, ".nodeterm-server").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(server, ".nodeterm-server").path, udOf(server)) + val fresh = freshHome() + File(fresh, ".nodeterm-server").apply { mkdirs() }.resolve("install-meta.json").writeText("{}") + assertEquals(File(fresh, ".nodeterm-server").path, udOf(fresh)) + + // --data-dir / NODETERM_DATA_DIR: found when the SSH session carries it, before the default. + val custom = freshHome() + val elsewhere = File(custom, "srv data").apply { mkdirs() } + elsewhere.resolve("agent-status.json").writeText("{}") + File(custom, ".nodeterm-server").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(elsewhere.path, udOf(custom, mapOf("NODETERM_DATA_DIR" to elsewhere.path))) + // …and without it, a server elsewhere is not found (the app says "not found", never "none"). + val hidden = freshHome() + File(hidden, "srv").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals("", udOf(hidden)) + + // A nodeterm desktop app on the same account wins, as before. + val both = freshHome() + File(both, ".config/node-terminal").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + File(both, ".nodeterm-server").apply { mkdirs() }.resolve("workspace.json").writeText("{}") + assertEquals(File(both, ".config/node-terminal").path, udOf(both)) + listOf(server, fresh, custom, hidden, both).forEach { it.deleteRecursively() } + } + + /** + * A stand-in tmux: `list-sessions` prints `/.txt` as given, `has-session -t =` + * answers from the first column of the same file. Enough to run the browse and socket scripts' + * own shell logic under /bin/sh with tricky input; the real tmux runs in SshTransportTest. + */ + private fun fakeTmux(home: File, sockets: Map): Map { + val bin = File(home, "fake-bin").apply { mkdirs() } + val data = File(home, "fake-tmux").apply { mkdirs() } + for ((socket, text) in sockets) File(data, "$socket.txt").writeText(text) + File(bin, "tmux").apply { + writeText( + "#!/bin/sh\n" + + "f=\"${'$'}FAKE_TMUX/${'$'}2.txt\"\n" + + "case \"${'$'}3\" in\n" + + " list-sessions) cat \"${'$'}f\" 2>/dev/null ;;\n" + + " has-session) n=${'$'}{5#=}; [ -f \"${'$'}f\" ] && cut -d' ' -f1 \"${'$'}f\" | grep -qx -- \"${'$'}n\" ;;\n" + + " *) exit 1 ;;\n" + + "esac\n" + ) + setExecutable(true) + } + return mapOf("PATH" to "${bin.path}:/usr/bin:/bin", "FAKE_TMUX" to data.path) + } + + @Test + fun `browse lists the rmt sessions, walks up to each project file once, and prints the status slices`() { + assumeTrue(File("/bin/sh").canExecute()) + val home = freshHome() + File(home, ".config/node-terminal").apply { mkdirs() }.resolve("workspace.json").writeText("""{"version":3,"entries":[]}""") + // Two projects, one inside the other; a folder with a space; sessions deep below them. + val outer = File(home, "work space/outer").apply { mkdirs() } + File(outer, ".nodeterm").mkdirs() + File(outer, ".nodeterm/project.json").writeText("""{"id":"project-outer","name":"Outer"}""") + val inner = File(outer, "pkg/inner").apply { mkdirs() } + File(inner, ".nodeterm").mkdirs() + File(inner, ".nodeterm/project.json").writeText("""{"id":"project-inner","name":"Inner"}""") + val deep = File(inner, "a/b").apply { mkdirs() } + val dot = File(home, ".nodeterm").apply { mkdirs() } + File(dot, "agent-status-project-outer.json").writeText("""{"v":1,"updatedAt":1,"nodes":{}}""") + File(dot, "agent-status-bad id.json").writeText("{}") + File(dot, "agent-status.json").writeText("{}") // not a slice: no `-` + val env = fakeTmux( + home, + mapOf( + "node-terminal" to "nt-own-1\n", + "nodeterm-rmt" to listOf( + "nt-r-1 ${deep.path}", + "nt-r-2 ${outer.path}", + "nt-r-3 ${inner.path}", // the inner file again: printed once + "nt-r-4 relative/path", // not absolute: no walk + "nt-bad;rm ${outer.path}", // not a session name this app makes: dropped + "other ${outer.path}", + "nt-r-5" // no path at all + ).joinToString("\n") + "\n" + ) + ) + val raw = sh(SshScripts.browse(), home, env) + val out = HostBrowse.split(raw) + assertEquals(File(home, ".config/node-terminal").path, out.userData) + assertTrue(out.blob.contains("nt-own-1"), out.blob) + assertEquals(listOf("nt-r-1", "nt-r-2", "nt-r-3", "nt-r-4", "nt-r-5"), out.rmtSessions) + assertEquals(listOf(inner.path, outer.path), out.projectFiles.map { it.first }, "nearest first, each once: $raw") + assertTrue(out.projectFiles[0].second.contains("project-inner")) + assertEquals(listOf("project-outer"), out.slices.map { it.first }, "only well-formed slice names") + assertTrue(out.slices.single().second.contains("\"updatedAt\":1")) + assertTrue(raw.trimEnd().endsWith(SshScripts.END_MARK), raw) + home.deleteRecursively() + } + + @Test + fun `the socket lookup finds a session on either socket, the host's own first`() { + assumeTrue(File("/bin/sh").canExecute()) + val home = freshHome() + val env = fakeTmux(home, mapOf("node-terminal" to "nt-both\nnt-own\n", "nodeterm-rmt" to "nt-both /x\nnt-drv /y\n")) + fun which(node: String) = sh(SshScripts.whichSocket(node), home, env).trim() + assertEquals("node-terminal", which("own")) + assertEquals("nodeterm-rmt", which("drv")) + assertEquals("node-terminal", which("both"), "a name on both is the host's own session") + assertEquals("", which("none")) + home.deleteRecursively() + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshSendKeysTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshSendKeysTest.kt new file mode 100644 index 0000000000..a26131d0a8 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshSendKeysTest.kt @@ -0,0 +1,163 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.ssh.SshScripts +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** The generated quick-answer shell, including errors tmux can report before accepting input. */ +class SshSendKeysTest { + private fun run(argv: List, home: File, env: Map): Pair { + val pb = ProcessBuilder(argv).directory(home).redirectErrorStream(true) + pb.environment().clear() + pb.environment().putAll(mapOf("HOME" to home.path, "PATH" to "/usr/bin:/bin") + env) + val p = pb.start() + val out = p.inputStream.bufferedReader().readText() + return p.waitFor() to out + } + + private fun shell(script: String, home: File, env: Map): Pair = + run(listOf("/bin/sh", "-c", script), home, env) + + /** + * A tmux edge double that keeps copy mode until cancelled. Ordinary send-keys in copy mode still + * succeeds, as real tmux does, but records delivery to the mode instead of the application. + */ + private fun fakeTmux(home: File, socket: String, mode: Int): Map { + val bin = File(home, "bin").apply { mkdirs() } + File(home, "mode").writeText(mode.toString()) + File(bin, "tmux").apply { + writeText(""" + #!/bin/sh + [ "${'$'}1" = -L ] && [ "${'$'}2" = "${'$'}FAKE_SOCKET" ] || exit 2 + case "${'$'}3" in + display-message) + printf 'resolve:%s\n' "${'$'}6" >> "${'$'}HOME/log" + [ "${'$'}4" = -p ] && [ "${'$'}5" = -t ] && [ "${'$'}6" = '=nt-answer:' ] || exit 3 + [ "${'$'}{FAKE_RESOLVE_EXIT:-0}" = 0 ] || exit "${'$'}FAKE_RESOLVE_EXIT" + printf '%s\n' "${'$'}{FAKE_STATE:-%42 ${'$'}(cat "${'$'}HOME/mode")}" ;; + send-keys) + [ "${'$'}4" = -t ] && [ "${'$'}5" = %42 ] || exit 4 + if [ "${'$'}6" = -X ]; then + [ "${'$'}7" = cancel ] || exit 5 + printf 'cancel:%s\n' "${'$'}5" >> "${'$'}HOME/log" + [ "${'$'}{FAKE_CANCEL_EXIT:-0}" = 0 ] || exit "${'$'}FAKE_CANCEL_EXIT" + printf 0 > "${'$'}HOME/mode" + else + printf 'send:%s\n' "${'$'}5" >> "${'$'}HOME/log" + if [ "${'$'}(cat "${'$'}HOME/mode")" = 0 ]; then printf pane; else printf copy; fi > "${'$'}HOME/delivery" + if [ "${'$'}6" = -l ]; then + [ "${'$'}7" = -- ] || exit 6 + printf '%s' "${'$'}8" > "${'$'}HOME/keys" + else + printf '%s' "${'$'}6" > "${'$'}HOME/keys" + fi + exit "${'$'}{FAKE_SEND_EXIT:-0}" + fi ;; + *) exit 1 ;; + esac + """.trimIndent() + "\n") + setExecutable(true) + } + return mapOf("PATH" to "${bin.path}:/usr/bin:/bin", "FAKE_SOCKET" to socket, + "TMUX_TMPDIR" to File(home, "tmux").apply { mkdirs() }.path) + } + + @Test + fun `literal answers Enter and Escape leave copy mode and use the resolved pane on either socket`() { + assumeTrue(File("/bin/sh").canExecute()) + val keys = listOf("-R; quote's ${'$'}literal\nnext" to "-R; quote's ${'$'}literal\nnext", "\r" to "Enter", "\u001b" to "Escape") + for (socket in listOf(TmuxNames.SOCKET, TmuxNames.REMOTE_SOCKET)) for (mode in listOf(0, 1)) for ((input, expected) in keys) { + val home = Files.createTempDirectory("nt-send-").toFile() + try { + val env = fakeTmux(home, socket, mode) + val (code, out) = shell(SshScripts.sendKeys("answer", input, socket), home, env) + assertEquals(0, code, out) + assertEquals("pane", File(home, "delivery").readText(), "copy mode must not swallow a successful answer") + assertEquals(expected, File(home, "keys").readText()) + assertEquals("resolve:=nt-answer:\n" + (if (mode == 1) "cancel:%42\n" else "") + "send:%42\n", File(home, "log").readText()) + } finally { + home.deleteRecursively() + } + } + } + + @Test + fun `a failed exact pane lookup or copy mode cancellation never sends the answer`() { + assumeTrue(File("/bin/sh").canExecute()) + for ((extra, expectedExit, expectedLog) in listOf( + Triple(mapOf("FAKE_RESOLVE_EXIT" to "3"), 3, "resolve:=nt-answer:\n"), + Triple(mapOf("FAKE_CANCEL_EXIT" to "7"), 7, "resolve:=nt-answer:\ncancel:%42\n"), + Triple(mapOf("FAKE_STATE" to "%oops 1"), 1, "resolve:=nt-answer:\n"), + Triple(mapOf("FAKE_STATE" to "42 0"), 1, "resolve:=nt-answer:\n"), + Triple(mapOf("FAKE_STATE" to "%42 unknown"), 1, "resolve:=nt-answer:\n") + )) { + val home = Files.createTempDirectory("nt-send-fail-").toFile() + try { + val env = fakeTmux(home, "node-terminal", 1) + extra + assertEquals(expectedExit, shell(SshScripts.sendKeys("answer", "1", "node-terminal"), home, env).first) + assertEquals(expectedLog, File(home, "log").readText()) + assertFalse(File(home, "delivery").exists(), "an unconfirmed cancellation must not type") + } finally { + home.deleteRecursively() + } + } + } + + @Test + fun `a send keys failure remains a failure after leaving copy mode`() { + assumeTrue(File("/bin/sh").canExecute()) + val home = Files.createTempDirectory("nt-send-exit-").toFile() + try { + val env = fakeTmux(home, "node-terminal", 1) + mapOf("FAKE_SEND_EXIT" to "9") + assertEquals(9, shell(SshScripts.sendKeys("answer", "1", "node-terminal"), home, env).first) + assertEquals("pane", File(home, "delivery").readText()) + } finally { + home.deleteRecursively() + } + } + + @Test + fun `real tmux delivers a quick answer out of copy mode and never matches a longer session name`() { + assumeTrue(File("/bin/sh").canExecute()) + assumeTrue(runCatching { ProcessBuilder("tmux", "-V").start().waitFor() == 0 }.getOrDefault(false), "tmux required") + // A short private socket directory, never the developer's live server (CLAUDE.md, #629). + val root = ShortTmuxRoot.create("nt-send", "tmux", "node-terminal") + val home = File(root, "home").apply { mkdirs() } + val env = mapOf("TMUX_TMPDIR" to File(root, "tmux").apply { mkdirs() }.path) + fun tmux(socket: String, vararg args: String) = run(listOf("tmux", "-L", socket) + args, home, env) + val reader = "while IFS= read -r line; do printf '%s\\n' \"${'$'}line\" >> \"${'$'}HOME/received\"; done" + try { + for (socket in listOf(TmuxNames.SOCKET, TmuxNames.REMOTE_SOCKET)) { + for (name in listOf("nt-answer", "nt-answer-longer")) { + val (code, out) = tmux(socket, "-f", "/dev/null", "new-session", "-d", "-s", name, "/bin/sh -c " + SshScripts.q(reader)) + assertEquals(0, code, out) + assertEquals(0, tmux(socket, "copy-mode", "-t", "=$name:").first) + } + assertEquals("1", tmux(socket, "display-message", "-p", "-t", "=nt-answer:", "#{pane_in_mode}").second.trim()) + assertEquals(0, shell(SshScripts.sendKeys("answer", "answer-$socket", socket), home, env).first) + assertEquals(0, shell(SshScripts.sendKeys("answer", "\r", socket), home, env).first) + val end = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5) + while (runCatching { File(home, "received").readText() }.getOrNull() != "answer-$socket\n" && System.nanoTime() < end) Thread.sleep(20) + assertEquals("answer-$socket\n", File(home, "received").readText()) + assertEquals("0", tmux(socket, "display-message", "-p", "-t", "=nt-answer:", "#{pane_in_mode}").second.trim()) + assertEquals("1", tmux(socket, "display-message", "-p", "-t", "=nt-answer-longer:", "#{pane_in_mode}").second.trim()) + + assertEquals(0, tmux(socket, "kill-session", "-t", "=nt-answer").first) + assertTrue(shell(SshScripts.sendKeys("answer", "wrong session", socket), home, env).first != 0) + assertEquals("1", tmux(socket, "display-message", "-p", "-t", "=nt-answer-longer:", "#{pane_in_mode}").second.trim()) + assertEquals("answer-$socket\n", File(home, "received").readText(), "a prefix match must never receive input") + assertEquals(0, tmux(socket, "kill-server").first) + File(home, "received").delete() + } + } finally { + for (socket in TmuxNames.SOCKETS) tmux(socket, "kill-server") + root.deleteRecursively() + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTerminalCreationTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTerminalCreationTest.kt new file mode 100644 index 0000000000..bbe7d5af62 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTerminalCreationTest.kt @@ -0,0 +1,191 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.ProjectInfo +import dev.nodeterm.protocol.model.ProjectsSnapshot +import dev.nodeterm.protocol.model.SshTerminalCreation +import dev.nodeterm.protocol.model.SshTerminalFolders +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SshTerminalCreationTest { + private val id = "phone-00000000-0000-4000-8000-000000000001" + + @Test fun `double tap cannot admit a second creation while the first waits`() = runBlocking { + val release = CompletableDeferred() + val calls = mutableListOf() + val creation = SshTerminalCreation(this, { calls += it; release.await() }, newId = { id }) + val ticket = creation.show() + assertTrue(creation.submit(ticket, null) {}) + assertFalse(creation.submit(ticket, "/other") {}) + yield() + assertEquals(listOf(SshTerminalCreation.Request(id, null)), calls) + release.complete(Unit) + yield() + assertIs(creation.state.value) + } + + @Test fun `dismissal does not cancel creation or its relisting and never navigates`() = runBlocking { + val release = CompletableDeferred() + val calls = mutableListOf() + val creation = SshTerminalCreation(this, { calls += "create"; release.await(); calls += "relist" }, newId = { id }) + val ticket = creation.show() + creation.submit(ticket, "/work") { calls += "navigate" } + yield() + creation.hide(ticket) + release.complete(Unit) + yield() + assertEquals(listOf("create", "relist"), calls) + assertIs(creation.state.value) + assertNull(creation.takeReady(ticket)) + val reopened = creation.show() + assertEquals(SshTerminalCreation.Request(id, "/work"), creation.takeReady(reopened)) + assertIs(creation.state.value) + } + + @Test fun `reopened dialog cannot adopt navigation from an earlier submission`() = runBlocking { + val release = CompletableDeferred() + var navigated = false + val creation = SshTerminalCreation(this, { release.await() }, newId = { id }) + val old = creation.show() + creation.submit(old, null) { navigated = true } + yield() + creation.hide(old) + val current = creation.show() + creation.hide(old) // An old disposal must not retire the new dialog. + release.complete(Unit) + yield() + assertFalse(navigated) + assertTrue(creation.isCurrent(current)) + assertEquals(id, creation.takeReady(current)?.nodeId) + } + + @Test fun `success becomes ready before visible navigation and only consumes once`() = runBlocking { + val creation = SshTerminalCreation(this, {}, newId = { id }) + val ticket = creation.show() + val calls = mutableListOf() + creation.submit(ticket, null) { calls += creation.takeReady(ticket)!!.nodeId } + yield() + assertEquals(listOf(id), calls) + assertNull(creation.takeReady(ticket)) + } + + @Test fun `UI callback rechecks a ticket retired after completion but before navigation`() = runBlocking { + val callbackEntered = CompletableDeferred() + val releaseCallback = CompletableDeferred() + var navigated = false + val creation = SshTerminalCreation(this, {}, newId = { id }) + val ticket = creation.show() + creation.submit(ticket, null) { + callbackEntered.complete(Unit) + releaseCallback.await() + navigated = creation.takeReady(ticket) != null + } + callbackEntered.await() + creation.hide(ticket) + releaseCallback.complete(Unit) + yield() + assertFalse(navigated) + assertIs(creation.state.value) + } + + @Test fun `lost reply retries one UUID and frozen folder rather than duplicating`() = runBlocking { + val calls = mutableListOf() + var minted = 0 + val creation = SshTerminalCreation(this, { + calls += it + if (calls.size == 1) throw Exception("reply lost") + }, newId = { minted++; id }) + val ticket = creation.show() + creation.submit(ticket, "/work with spaces") {} + yield() + val failed = assertIs(creation.state.value) + assertEquals("reply lost", failed.message) + assertFalse(failed.canChangeFolder) + assertFalse(creation.submit(ticket, "/different") {}) + assertTrue(creation.submit(ticket, "/work with spaces") {}) + yield() + assertEquals(1, minted) + assertEquals(listOf(failed.request, failed.request), calls) + } + + @Test fun `lost reply survives dialog replacement and retries its original request`() = runBlocking { + val calls = mutableListOf() + val creation = SshTerminalCreation(this, { calls += it; throw Exception("lost") }, newId = { id }) + val old = creation.show() + creation.submit(old, null) {} + yield() + creation.hide(old) + val replacement = creation.show() + assertFalse(creation.submit(old, null) {}) + assertTrue(creation.submit(replacement, null) {}) + yield() + assertEquals(2, calls.size) + assertEquals(calls[0], calls[1]) + } + + @Test fun `definitive folder refusal permits a new choice and a fresh operation identity`() = runBlocking { + var minted = 0 + val calls = mutableListOf() + val creation = SshTerminalCreation(this, { + calls += it + if (calls.size == 1) throw IllegalArgumentException("Folder does not exist") + }, newId = { "phone-00000000-0000-4000-8000-${(++minted).toString().padStart(12, '0')}" }) + val ticket = creation.show() + creation.submit(ticket, "/missing") {} + yield() + assertTrue(assertIs(creation.state.value).canChangeFolder) + assertTrue(creation.submit(ticket, "/exists") {}) + yield() + assertEquals(2, minted) + assertEquals("/exists", calls.last().cwd) + } + + @Test fun `invalid local folder never reaches SSH or consumes an identity`() = runBlocking { + var calls = 0 + val creation = SshTerminalCreation(this, { calls++ }, newId = { error("Must not mint") }) + val ticket = creation.show() + for (folder in listOf("relative", "", "/x\n", "/x\u0085", "/" + "x".repeat(4096))) { + assertFalse(creation.submit(ticket, folder) {}) + } + yield() + assertEquals(0, calls) + assertIs(creation.state.value) + } + + @Test fun `cancelled transport attempt stays uncertain and cannot report success`() = runBlocking { + val job = Job() + val started = CompletableDeferred() + val creation = SshTerminalCreation(CoroutineScope(coroutineContext + job), { started.complete(Unit); CompletableDeferred().await() }, newId = { id }) + val ticket = creation.show() + var navigated = false + creation.submit(ticket, null) { navigated = true } + started.await() + job.cancel() + job.join() + val failed = assertIs(creation.state.value) + assertFalse(failed.canChangeFolder) + assertFalse(navigated) + assertTrue(job.isCancelled) + } + + @Test fun `folder choices include home and local driven folders but exclude other SSH hosts`() { + fun project(id: String, cwd: String?, remote: Boolean = false, target: String? = null, closed: Boolean = false) = + ProjectInfo(id, id, "#ffffff", cwd, target, closed, emptyList(), null, drivenRemotely = remote) + val snapshot = ProjectsSnapshot( + listOf(project("local", "/work"), project("duplicate", "/work"), project("driven", "/remote-work", true), + project("other-host", "/other", target = "elsewhere"), project("relative", "./bad"), + project("no-folder", null), project("control", "/bad\t"), project("closed", "/still-existing", closed = true)), + emptySet(), null, 1 + ) + assertEquals(listOf(null, "/work", "/remote-work", "/still-existing"), SshTerminalFolders.choices(snapshot).map { it.cwd }) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTerminalCreationWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTerminalCreationWiringTest.kt new file mode 100644 index 0000000000..9f226850b8 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTerminalCreationWiringTest.kt @@ -0,0 +1,63 @@ +package dev.nodeterm.protocol + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Native lifecycle/main-thread pins complement the executable operation tests. */ +class SshTerminalCreationWiringTest { + @Test fun `plain terminal is available on SSH without relying on registered desktop projects`() { + val source = AppSourcePins.ui("HostScreen.kt") + assertTrue(source.contains("val offersSshTerminal = (state as? ConnState.Connected)?.kind == TransportKind.SSH")) + val fab = AppSourcePins.blockAfter(source, "floatingActionButton =") + assertTrue(fab.contains("tab == 0 && (offersSshTerminal || offersNew)")) + assertTrue(fab.contains("terminalTicket = creation.show()")) + assertTrue(fab.contains("Text(\"New terminal\")")) + assertTrue(source.contains("PendingLaunches.put(request)"), "The existing relay New session path remains") + } + + @Test fun `host owns durable creation and background hold includes relisting after failure`() { + val source = AppSourcePins.app("conn/ConnectionManager.kt") + assertTrue(source.contains("val sshTerminalCreation = SshTerminalCreation(graph.scope")) + val create = AppSourcePins.blockAfter(source, "suspend fun createAndRefreshSshTerminal") + AppSourcePins.assertInOrder(create, "ensureConnected(Trigger.BACKGROUND) as? SshHostConnection", "ssh.createTerminal(nodeId, cwd)", "finally", "refreshNow(Trigger.BACKGROUND)") + assertTrue(source.contains("suspend fun createAndRefreshSshTerminal(nodeId: String, cwd: String?) = inBackground")) + assertFalse(create.contains("REGISTER_NODE")) + assertTrue(source.contains("it is SshTerminalCreationRefusedException")) + } + + @Test fun `late success switches to Main and checks visible entry before ordinary navigation`() { + val source = AppSourcePins.ui("HostScreen.kt") + val open = AppSourcePins.blockAfter(source, "fun openCreated(ticket: Long)") + AppSourcePins.assertInOrder(open, "!hostStarted", "terminalTicket != ticket", "nav.top.key != screenKey", "creation.takeReady(ticket)", "nav.push") + assertTrue(source.contains("withContext(Dispatchers.Main) { openCreated(ticket) }")) + val plain = AppSourcePins.blockAfter(source, "terminalTicket?.let { ticket ->") + assertFalse(plain.contains("PendingLaunches")) + assertTrue(plain.contains("creation.submit(ticket, cwd)")) + val life = AppSourcePins.blockAfter(source, "LifecycleStartEffect(hostId)") + assertTrue(life.contains("hostStarted = false")) + assertTrue(life.contains("terminalTicket?.let(creation::hide)")) + val dispose = AppSourcePins.blockAfter(source, "DisposableEffect(creation)") + assertTrue(dispose.contains("terminalTicket?.let(creation::hide)")) + } + + @Test fun `dialog freezes uncertain request but allows correcting a definitive refusal`() { + val source = AppSourcePins.ui("SshTerminalDialog.kt") + assertTrue(source.contains("state is SshTerminalCreation.State.Failed && state.canChangeFolder")) + assertTrue(source.contains("enabled = !busy && valid")) + assertTrue(source.contains("onCreate(if (editable) selected else request?.cwd)")) + assertTrue(source.contains("PhoneTerminals.validCwd(selected)")) + assertTrue(source.contains("SshTerminalFolders.choices(snapshot)")) + } + + @Test fun `phone rows expose only relevant actions and their starting folder`() { + val source = AppSourcePins.ui("SessionsTab.kt") + val noCanvasActions = AppSourcePins.blockAfter(source, "if (!PhoneTerminals.validId(node.id))") + assertTrue(noCanvasActions.contains("Refresh view on computer")) + assertTrue(noCanvasActions.contains("Rename…")) + assertTrue(source.contains("if (project.id != PhoneTerminals.PROJECT_ID)")) + assertTrue(source.contains("if (PhoneTerminals.validId(node.id)) node.cwd?.let { add(it) }")) + assertTrue(source.contains("removes it from the phone terminals list")) + assertTrue(source.contains("PhoneTerminals.validId(node.id) && c !is SshHostConnection")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHost.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHost.kt new file mode 100644 index 0000000000..116a47343c --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHost.kt @@ -0,0 +1,133 @@ +package dev.nodeterm.protocol + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.util.concurrent.TimeUnit + +// What the machine running SshTransportTest has to provide, and the two places where the platforms +// contributors use differ (audit A62): where a tmux socket can be bound, and which `script(1)` stands +// in for sshd's pty. Both were Linux-only, so on a Mac with Homebrew tmux the audit found every test +// of that class failing instead of running or skipping. Only the Linux leg has run here. + +/** + * The longest unix socket path that can be bound. macOS `sys/un.h` declares `sun_path[104]`, which is + * 103 characters plus the NUL; Linux allows 107. The smaller number holds everywhere, as in + * `src/core/tmux-test-socket.ts`: a limit that only bites on a Mac looks like a broken test. + */ +internal const val SUN_PATH_MAX = 103 + +/** + * A private temp root for the SSH transport tests, short enough that `/` works as + * `TMUX_TMPDIR` for `-L `. + * + * The JVM's temp dir on a Mac is `/var/folders/…/T/`, which tmux resolves to `/private/var/folders/…` + * before it binds. Under it the socket path was about 110 characters, and `tmux new-session` failed + * with "File name too long" in `@BeforeAll`. So `/tmp` is tried first (`/private/tmp` on a Mac, + * `/tmp` itself on Linux, where the JVM's temp dir usually already is `/tmp`), and every base is + * resolved before anything is measured. + */ +internal object ShortTmuxRoot { + /** Where tmux binds `-L ` under `TMUX_TMPDIR=` for [uid]. */ + fun socketPath(tmuxDir: File, uid: Int, socket: String): String = "${tmuxDir.path}/tmux-$uid/$socket" + + fun defaultBases(): List = listOf(File("/tmp"), File(System.getProperty("java.io.tmpdir"))) + + /** + * Create the root under the first of [bases] that leaves room for the socket, and return it + * resolved. Each base is resolved first, because tmux resolves its directory before binding and + * measuring the unresolved spelling under-counts. The length is then measured on the directory + * actually created, with the uid that owns it (the uid tmux names its `tmux-` directory + * after). The directory made under a base without room is removed and the next base tried; when + * none has room this throws, naming the lengths, instead of handing back a path that fails at bind + * time. + */ + fun create(prefix: String, tmuxSubdir: String, socket: String, bases: List = defaultBases()): File { + val tried = ArrayList() + for (base in bases.mapNotNull { resolved(it) }.distinct()) { + val root = try { + Files.createTempDirectory(base.toPath(), prefix).toRealPath().toFile() + } catch (e: IOException) { + tried += "$base (${e.message})" + continue + } + val uid = Files.getAttribute(root.toPath(), "unix:uid") as Int + val bound = socketPath(File(root, tmuxSubdir), uid, socket) + if (bound.length <= SUN_PATH_MAX) return root + tried += "$bound (${bound.length} chars)" + root.deleteRecursively() + } + throw IllegalStateException( + "no temp dir leaves room for a tmux socket within $SUN_PATH_MAX chars; tried: ${tried.joinToString("; ")}" + ) + } + + private fun resolved(dir: File): File? = + runCatching { dir.toPath().toRealPath().toFile() }.getOrNull()?.takeIf { it.isDirectory } +} + +/** + * The `script(1)` that gives a pty-requesting exec its pty, standing in for sshd (MINA's process + * bridge has none). The two in the field take different arguments: + * + * - **util-linux** (Linux): `script -qfec /dev/null`. `-c` runs the command through + * `$SHELL -c`, `-e` returns its exit status, `-q` drops the start/done banners. + * - **BSD** (macOS, FreeBSD): `script [-aeFkqr] [-t time] [file [command ...]]`. There is no `-c`: + * the command is the argv after the typescript file, exec'd as is. Its exit status should be the + * command's; [detect] checks that rather than trusting it. The command runs as + * `/bin/sh -c `, the shell the harness uses for commands without a pty. + * + * util-linux writes the terminal's EOF character into the pty when its stdin ends (measured), and + * FreeBSD's `script.c` does the same in canonical mode (macOS unmeasured), which is why the harness + * never closes a pty command's stdin. + */ +internal class PtyScript private constructor(val flavor: Flavor, private val executable: String) { + enum class Flavor { UTIL_LINUX, BSD } + + /** The argv that runs [command] (one shell line) under a fresh pty, with `/dev/null` as the typescript. */ + fun argv(command: String): List = when (flavor) { + Flavor.UTIL_LINUX -> listOf(executable, "-qfec", command, "/dev/null") + Flavor.BSD -> listOf(executable, "-q", "/dev/null", "/bin/sh", "-c", command) + } + + companion object { + const val SKIP_REASON = "the SSH transport tests need tmux and a script(1) that gives a command a pty: " + + "util-linux (Linux) or BSD (macOS)" + + private const val PROBE_SECONDS = 10L + + /** A command whose exit status 7 proves both that it had a tty and that the status came back. */ + internal const val PROBE_COMMAND = "[ -t 0 ] && [ -t 1 ] || exit 3; exit 7" + + fun of(flavor: Flavor, executable: String = "script") = PtyScript(flavor, executable) + + /** + * Which `script` [executable] is, or null for neither (the tests then skip with [SKIP_REASON]). + * util-linux names itself in `--version`, so Linux is recognised without running anything under + * a pty, exactly as before. BSD `script` has no `--version` (an unknown option prints the usage + * and exits 1), so it is recognised by running [PROBE_COMMAND] in its own argument form. + */ + fun detect(executable: String = "script"): PtyScript? { + val version = run(listOf(executable, "--version")) + if (version != null && version.first == 0 && "util-linux" in version.second) return of(Flavor.UTIL_LINUX, executable) + val bsd = of(Flavor.BSD, executable) + return bsd.takeIf { run(it.argv(PROBE_COMMAND))?.first == 7 } + } + + private fun run(argv: List): Pair? = runCatching { + // Run where a typescript some other script(1) might write cannot land in the source tree. + val p = ProcessBuilder(argv).directory(File(System.getProperty("java.io.tmpdir"))) + .redirectErrorStream(true).start() + p.outputStream.close() + val out = StringBuffer() + val reader = Thread { runCatching { out.append(p.inputStream.bufferedReader().readText()) } } + .apply { isDaemon = true; start() } + if (!p.waitFor(PROBE_SECONDS, TimeUnit.SECONDS)) { + p.destroyForcibly() + return@runCatching null + } + reader.join(2_000) + p.exitValue() to out.toString() + }.getOrNull() + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHostTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHostTest.kt new file mode 100644 index 0000000000..a6c38d56d4 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTestHostTest.kt @@ -0,0 +1,166 @@ +package dev.nodeterm.protocol + +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.nio.file.FileSystems +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The machine-facing half of [SshTransportTest] (audit A62): the temp root its tmux socket lives in, + * and the `script(1)` that gives its pty commands a pty. Both were Linux-only, so on a Mac the audit + * found every test of that class failing instead of running. Nothing here has run on a Mac: the macOS + * temp dir is stood in for by a symlink to a long directory, and BSD `script` by + * `bsd-script-standin.sh`, which follows BSD's documented argument grammar. + */ +class SshTestHostTest { + private val socket = "node-terminal" + + /** + * [ShortTmuxRoot] is POSIX-only by construction: it reads the owning uid through the `unix` + * attribute view (absent on Windows, where the read throws UnsupportedOperationException) and its + * first base is `/tmp`. So its tests skip where that is missing, the way [SshScriptsTest] gates on + * `/bin/sh`, instead of failing, which is the shape A62 was about. [SshTransportTest] never gets + * that far there: its tmux and `script(1)` gates skip it first. + */ + private fun assumePosixTemp() = assumeTrue( + "unix" in FileSystems.getDefault().supportedFileAttributeViews() && File("/tmp").isDirectory, + "ShortTmuxRoot needs a POSIX filesystem (the unix attribute view) and /tmp" + ) + + /** A real directory whose socket path cannot fit, whatever temp root it sits under. */ + private fun longDir(parent: File): File = + File(parent, "x".repeat(maxOf(1, 100 - parent.path.length))).apply { mkdirs() }.toPath().toRealPath().toFile() + + @Test + fun `a base that resolves to a long path is passed over, however short its own spelling`() { + assumePosixTemp() + // macOS's shape: the JVM's temp dir is spelled /var/folders/…, but tmux binds under + // /private/var/folders/…, 8 characters longer. Measuring the spelling said it fit when it did not. + val scratch = Files.createTempDirectory("nt-a62").toFile() + val links = Files.createTempDirectory(File("/tmp").toPath(), "nt-l").toFile() + try { + val long = longDir(scratch) + val link = File(links, "t") + Files.createSymbolicLink(link.toPath(), long.toPath()) + assertTrue( + ShortTmuxRoot.socketPath(File(link, "nt-t12345678901234567890/tmux"), 501, socket).length <= SUN_PATH_MAX, + "the stand-in must look short when measured unresolved" + ) + val root = ShortTmuxRoot.create("nt-t", "tmux", socket, bases = listOf(link, File("/tmp"))) + try { + assertEquals(File("/tmp").toPath().toRealPath(), root.toPath().parent, "picked $root") + assertEquals(root.toPath().toRealPath().toFile(), root, "TMUX_TMPDIR is handed over resolved") + val uid = Files.getAttribute(root.toPath(), "unix:uid") as Int + assertTrue(ShortTmuxRoot.socketPath(File(root, "tmux"), uid, socket).length <= SUN_PATH_MAX) + assertEquals(emptyList(), long.list()!!.toList(), "the directory made under the long base was removed") + } finally { + root.deleteRecursively() + } + } finally { + links.deleteRecursively() + scratch.deleteRecursively() + } + } + + @Test + fun `no base with room is a failure naming the lengths, never a path that fails at bind time`() { + assumePosixTemp() + val scratch = Files.createTempDirectory("nt-a62").toFile() + try { + val long = longDir(scratch) + val e = assertFailsWith { ShortTmuxRoot.create("nt-t", "tmux", socket, bases = listOf(long)) } + assertTrue(e.message!!.contains("$SUN_PATH_MAX chars") && e.message!!.contains(long.path), e.message) + assertEquals(emptyList(), long.list()!!.toList()) + } finally { + scratch.deleteRecursively() + } + } + + @Test + fun `util-linux keeps the arguments the harness always used`() { + assertEquals( + listOf("script", "-qfec", "stty size", "/dev/null"), + PtyScript.of(PtyScript.Flavor.UTIL_LINUX).argv("stty size") + ) + } + + @Test + fun `BSD gets the command as argv after the typescript file`() { + assertEquals( + listOf("script", "-q", "/dev/null", "/bin/sh", "-c", "stty size"), + PtyScript.of(PtyScript.Flavor.BSD).argv("stty size") + ) + } + + private fun runScript(argv: List): Pair { + val p = ProcessBuilder(argv).redirectErrorStream(true).start() + val out = StringBuffer() + val reader = Thread { runCatching { out.append(p.inputStream.bufferedReader().readText()) } } + .apply { isDaemon = true; start() } + // Like the harness's pty commands, stdin stays open while it runs (its EOF types a ^D). + try { + if (!p.waitFor(10, TimeUnit.SECONDS)) { + p.destroyForcibly() + throw AssertionError("$argv did not exit: $out") + } + } finally { + p.outputStream.close() + } + reader.join(2_000) + return p.exitValue() to out.toString() + } + + /** The command shape [SshTransportTest] runs under a pty: size the pty, then the product's line. */ + private val sizedCommand = "stty cols 100 rows 30 2>/dev/null; stty size; exit 5" + + @Test + fun `this machine's script gives the harness's command a pty and returns its status`() { + val script = PtyScript.detect() + assumeTrue(script != null, PtyScript.SKIP_REASON) + val (code, out) = runScript(script!!.argv(sizedCommand)) + assertEquals(5, code, out) + assertTrue(out.contains("30 100"), out) + } + + private fun executable(dir: File, name: String, text: String) = + File(dir, name).apply { writeText(text); setExecutable(true) } + + @Test + fun `a BSD-shaped script is recognised by its argument form, and runs the harness's command`() { + // The stand-in borrows its pty from util-linux. + assumeTrue(PtyScript.detect()?.flavor == PtyScript.Flavor.UTIL_LINUX, "the BSD stand-in needs util-linux script") + val dir = Files.createTempDirectory("nt-a62").toFile() + try { + val text = assertNotNull(javaClass.getResource("/bsd-script-standin.sh")).readText() + val bsd = executable(dir, "bsd-script", text) + val script = assertNotNull(PtyScript.detect(bsd.path)) + assertEquals(PtyScript.Flavor.BSD, script.flavor) + val (code, out) = runScript(script.argv(sizedCommand)) + assertEquals(5, code, out) + assertTrue(out.contains("30 100"), out) + } finally { + dir.deleteRecursively() + } + } + + @Test + fun `a script that answers neither form, or gives no pty, is not used`() { + val dir = Files.createTempDirectory("nt-a62").toFile() + try { + assertNull(PtyScript.detect(executable(dir, "refuses", "#!/bin/sh\nexit 1\n").path)) + // BSD's argument grammar without a pty: the probe asks for a tty, not just an exit status. + val noPty = executable(dir, "no-pty", "#!/bin/sh\nshift 2\nexec \"\$@\"\n") + assertNull(PtyScript.detect(noPty.path)) + assertNull(PtyScript.detect(File(dir, "missing").path)) + } finally { + dir.deleteRecursively() + } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt new file mode 100644 index 0000000000..0fc687d29b --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt @@ -0,0 +1,2757 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ManagedSessionAdoption +import dev.nodeterm.protocol.host.ManagedSessionChoice +import dev.nodeterm.protocol.host.ManagedSessionReceipt +import dev.nodeterm.protocol.host.PreparedManagedSession +import dev.nodeterm.protocol.host.ManagedSessions +import dev.nodeterm.protocol.ssh.SshActions +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import dev.nodeterm.protocol.host.ApprovalOutcome +import dev.nodeterm.protocol.host.Capability +import dev.nodeterm.protocol.host.CardLabelEdit +import dev.nodeterm.protocol.host.LegRouting +import dev.nodeterm.protocol.host.NewNode +import dev.nodeterm.protocol.host.HostException +import dev.nodeterm.protocol.host.HostUnansweredException +import dev.nodeterm.protocol.host.GitVerb +import dev.nodeterm.protocol.git.SourceControl +import dev.nodeterm.protocol.git.GitReplies +import dev.nodeterm.protocol.host.NeedsRelayException +import dev.nodeterm.protocol.host.ResumeOffer +import dev.nodeterm.protocol.host.TerminalActions +import dev.nodeterm.protocol.host.ComposedInput +import dev.nodeterm.protocol.host.ComposedInputResult +import dev.nodeterm.protocol.host.TerminalSink +import dev.nodeterm.protocol.model.AgentState +import dev.nodeterm.protocol.model.InboxEvent +import dev.nodeterm.protocol.model.InboxKind +import dev.nodeterm.protocol.model.NewSessionChoice +import dev.nodeterm.protocol.model.Pane +import dev.nodeterm.protocol.model.TmuxNames +import dev.nodeterm.protocol.pairing.SshIdentity +import dev.nodeterm.protocol.ssh.HostBrowse +import dev.nodeterm.protocol.ssh.HostKeyChangedException +import dev.nodeterm.protocol.ssh.HostKeyNotPairedException +import dev.nodeterm.protocol.ssh.HostKeyPin +import dev.nodeterm.protocol.ssh.NothingFoundException +import dev.nodeterm.protocol.ssh.SshHostConnection +import dev.nodeterm.protocol.ssh.SshScripts +import dev.nodeterm.protocol.ssh.SshInputViewer +import dev.nodeterm.protocol.ssh.PhoneTerminals +import dev.nodeterm.protocol.ssh.SshTerminalCreationRefusedException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import kotlinx.coroutines.async +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonPrimitive +import org.apache.sshd.server.Environment +import org.apache.sshd.server.ExitCallback +import org.apache.sshd.server.SshServer +import org.apache.sshd.server.channel.ChannelSession +import org.apache.sshd.server.command.Command +import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assumptions.assumeTrue +import kotlin.test.assertIs +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.TestInstance +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The direct-SSH transport end to end: a real SSH server (Apache MINA) running every command + * through a shell as the phone would get from sshd, against a fake HOME laid out like a desktop's + * (a v3 workspace index + project files + agent-status.json) and a REAL tmux — on a private + * `TMUX_TMPDIR`, so no test ever touches a tmux server a developer is using (issue #629's rule). + * + * An exec without a pty runs as `/bin/sh -c`. A pty-requesting exec runs under `script`, standing in + * for sshd's pty allocation (MINA's process bridge has none of its own); that wrapper is harness, the + * scripts it runs are the product's. Linux's util-linux `script` (which runs the command through + * `$SHELL`) and macOS's BSD one take different arguments, and the macOS temp dir is too long for a + * tmux socket: [PtyScript] and [ShortTmuxRoot] handle both (audit A62). + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SshTransportTest { + private lateinit var root: File + private lateinit var home: File + private lateinit var tmuxDir: File + private lateinit var server: SshServer + private lateinit var ptyScript: PtyScript + private val identity = SshIdentity.generate() + private var port = 0 + // An explicit non-login POSIX shell: a developer's login profiles can install a slow command- + // not-found handler, and their Readline config can change how the tested bytes are interpreted. + private val paneShell = arrayOf("/usr/bin/env", "ENV=", "BASH_ENV=", "INPUTRC=/dev/null", "/bin/sh") + /** Every public key the server was asked to accept, ours or not. */ + private val authAttempts = java.util.concurrent.atomic.AtomicInteger() + + private fun actionsFixture(host: SshTestHost) = InteropHarness.start("ssh-actions", InteropHarness.scratchHomeEnv(host.home) + + mapOf("FIXTURE_USERDATA" to File(host.home, ".config/node-terminal").path)) + + private fun relayAdvertisementFixture(host: SshTestHost): InteropHarness { + val nonce = java.util.UUID.randomUUID().toString().replace("-", "") + java.nio.file.Files.setPosixFilePermissions(host.home.toPath(), java.nio.file.attribute.PosixFilePermissions.fromString("rwx------")) + val marker = File(host.home, ".relay-advertisement-fixture").apply { writeText(nonce) } + java.nio.file.Files.setPosixFilePermissions(marker.toPath(), java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")) + val fixture = InteropHarness.start("relay-advertisement", mapOf("FIXTURE_HOME" to host.home.path, "FIXTURE_NONCE" to nonce)) + try { + assertEquals(true, fixture.ready.getValue("homeRestored").jsonPrimitive.boolean) + return fixture + } catch (failure: Throwable) { + fixture.close() + throw failure + } + } + + private fun publicAdvertisement(suffix: String) = buildJsonObject { + put("v", 1) + put("hostId", "public host α '$suffix") + put("hostPublicKeyB64", java.util.Base64.getEncoder().encodeToString(ByteArray(32) { (it + suffix.length).toByte() })) + put("relayEndpoint", "wss://relay.nodeterm.dev/$suffix") + put("hostDeviceId", "public device Ω \"$suffix\"") + } + + /** The producer seeds a whole workspace, so it must never use this class's desktop profile. */ + private inner class SshTestHost : AutoCloseable { + private val ownedRoot = ShortTmuxRoot.create("nt-actions", "tmux", "node-terminal") + val home = File(ownedRoot, "home").apply { mkdirs() } + private val tmuxDir = File(ownedRoot, "tmux").apply { mkdirs() } + private val server = SshServer.setUpDefaultServer() + @Volatile var onCommand: ((String, ShCommand) -> Unit)? = null + @Volatile var commandPath: String? = null + val privateTmuxDirectory: String get() = tmuxDir.path + + init { + try { + server.host = "127.0.0.1" + server.port = 0 + server.keyPairProvider = SimpleGeneratorHostKeyProvider(File(ownedRoot, "hostkey.ser").toPath()) + val expected = identity.keyPair.public.encoded + server.publickeyAuthenticator = org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator { user, key, _ -> + user == "dev" && key.encoded.contentEquals(expected) + } + server.commandFactory = org.apache.sshd.server.command.CommandFactory { _, command -> + ShCommand(command, home, childEnv(home, tmuxDir) + (commandPath?.let { mapOf("PATH" to it) } ?: emptyMap())).also { onCommand?.invoke(command, it) } + } + server.start() + } catch (e: Exception) { + runCatching { server.stop(true) } + ownedRoot.deleteRecursively() + throw e + } + } + + fun connect(profilePath: String? = null) = SshHostConnection.connect("127.0.0.1", server.port, "dev", identity, MemoryPin(), profilePath = profilePath) + + fun tmux(vararg args: String): Pair { + val p = ProcessBuilder(listOf("tmux", "-L", "node-terminal") + args).redirectErrorStream(true).apply { + environment().clear(); environment().putAll(childEnv(home, tmuxDir)) + }.start() + val text = p.inputStream.bufferedReader().readText(); return p.waitFor() to text + } + fun execute(script: String): Pair { + val p = ProcessBuilder("/bin/sh", "-c", script).directory(home).redirectErrorStream(true).apply { + environment().clear(); environment().putAll(childEnv(home, tmuxDir) + (commandPath?.let { mapOf("PATH" to it) } ?: emptyMap())) + }.start() + val text = p.inputStream.bufferedReader().readText(); return p.waitFor() to text + } + fun advertisement(instance: String): SshActions.Advertisement { + val ud = File(home, ".config/node-terminal").apply { mkdirs() } + val root = File(ud, "ssh-actions").apply { mkdirs(); setReadable(false, false); setWritable(false, false); setExecutable(false, false); setReadable(true, true); setWritable(true, true); setExecutable(true, true) } + File(root, instance).apply { mkdirs(); setReadable(false, false); setWritable(false, false); setExecutable(false, false); setReadable(true, true); setWritable(true, true); setExecutable(true, true) } + val stamp = System.currentTimeMillis() + val raw = buildJsonObject { put("version", 1); put("instance", instance); put("pid", ProcessHandle.current().pid()); put("updatedAt", stamp); put("remoteProjects", false); put("methods", JsonArray(listOf(JsonPrimitive(ManagedSessions.METHOD)))) }.toString() + File(root, "advertisement.json").apply { writeText(raw); setReadable(false, false); setWritable(false, false); setReadable(true, true); setWritable(true, true) } + return assertNotNull(SshActions.parseAdvertisement("NT-ACTIONS-1\t$stamp\n$raw")) + } + override fun close() { + try { server.stop(true) } finally { runCatching { tmux("kill-server") }; ownedRoot.deleteRecursively() } + } + } + + /** Genuine Server services publish under resolveConfig(--data-dir); native fixture is explicit. */ + private fun serverProfileFixture(host: SshTestHost, profile: File): InteropHarness { + val lookup = ProcessBuilder("/bin/sh", "-c", "command -v tmux").start() + val tmux = lookup.inputStream.bufferedReader().readText().trim() + assertEquals(0, lookup.waitFor()); assertTrue(File(tmux).isFile) + return InteropHarness.start("server-profile", mapOf( + "FIXTURE_HOME" to host.home.path, "FIXTURE_USERDATA" to profile.path, + "FIXTURE_TMUX_DIR" to host.privateTmuxDirectory, "FIXTURE_TMUX_BIN" to tmux + )) + } + + @Test fun `actual account advertisement writer reaches the SSH parser and replaces every public field`() = runBlocking { + SshTestHost().use { host -> + relayAdvertisementFixture(host).use { fixture -> + host.connect().use { connection -> + assertNull(connection.readRelayAdvertisement(), "no advertisement before the real writer runs") + for (value in listOf(publicAdvertisement("first"), publicAdvertisement("replacement"))) { + fixture.command("write $value") + fixture.awaitEvent("written") + assertEquals(value, connection.readRelayAdvertisement(), "the actual writer's public file reaches the real SSH parser") + val file = File(host.home, ".nodeterm/relay.json") + assertEquals(java.nio.file.attribute.PosixFilePermissions.fromString("rw-------"), java.nio.file.Files.getPosixFilePermissions(file.toPath())) + assertEquals(java.nio.file.attribute.PosixFilePermissions.fromString("rwx------"), java.nio.file.Files.getPosixFilePermissions(file.parentFile.toPath())) + } + fixture.command("remove") + fixture.awaitEvent("removed") + assertNull(connection.readRelayAdvertisement(), "the actual remover makes late-adoption material absent") + assertFalse(File(host.home, ".nodeterm/relay.json").exists()) + fixture.command("remove") + fixture.awaitEvent("removed") + assertNull(connection.readRelayAdvertisement(), "removing an absent advertisement remains harmless") + } + } + } + } + + @Test fun `relay advertisement stays account scoped across explicit and absent SSH profiles`() = runBlocking { + SshTestHost().use { host -> + val profile = File(host.home, "profiles/custom α ' folder").apply { mkdirs() } + val decoy = publicAdvertisement("unrelated-profile") + File(profile, "relay.json").writeText(decoy.toString()) + relayAdvertisementFixture(host).use { fixture -> + val profiles = listOf(null, profile.path, File(host.home, "profiles/missing").path) + for (choice in profiles) host.connect(choice).use { assertNull(it.readRelayAdvertisement(), "a profile file is never an account advertisement") } + val value = publicAdvertisement("account-level") + fixture.command("write $value") + fixture.awaitEvent("written") + for (choice in profiles) host.connect(choice).use { + assertEquals(value, it.readRelayAdvertisement(), "profile selection affects browse, not the account-level public advertisement") + } + fixture.command("remove") + fixture.awaitEvent("removed") + for (choice in profiles) host.connect(choice).use { assertNull(it.readRelayAdvertisement()) } + assertEquals(decoy.toString(), File(profile, "relay.json").readText(), "removal must not touch unrelated profile files") + } + } + } + + @Test fun `explicit Server profile reads actual custom publication and Board instead of coexisting desktop`() = runBlocking { + SshTestHost().use { host -> + val profile = File(host.home, "profiles/Server α ' quoted") + serverProfileFixture(host, profile).use { fixture -> + host.connect().use { automatic -> + assertEquals(listOf("server-default"), automatic.listProjects().projects.map { it.id }) + assertFalse(automatic.capabilities.boardWrites, "a different profile's service is never borrowed") + } + host.connect(profile.path).use { conn -> + val listed = conn.listProjects() + assertEquals(listOf("server-custom"), listed.projects.map { it.id }) + assertEquals(AgentState.DONE, listed.statusOf("term-server-seed")?.state) + assertEquals("fixture-custom", listed.status?.server?.version) + assertTrue(conn.capabilities.boardWrites) + assertTrue(conn.capabilities.managedCreate) + assertFalse(conn.capabilities.nodeActions, "Server still has no Desktop renderer nudge") + val board = conn.ensureBoard("server-custom") + assertEquals(3, assertNotNull(board).size) + fixture.command("snapshot") + val proof = fixture.awaitEvent("snapshot") + assertEquals(3, proof.getValue("file").jsonObject.getValue("kanban").jsonObject.getValue("columns").jsonArray.size) + assertNull(proof.getValue("defaultFile").jsonObject["kanban"], "the checked default project stays unchanged") + } + } + } + } + + @Test fun `custom Server profile creates actual owned shell and all three receipt view stages adopt it`() = runBlocking { + SshTestHost().use { host -> + val profile = File(host.home, "profiles/custom managed") + serverProfileFixture(host, profile).use { fixture -> + var adoption: ManagedSessionAdoption? = null + val stages = java.util.concurrent.atomic.AtomicInteger() + host.onCommand = { command, _ -> if (command.contains("NT-MANAGED-VERIFIED") || command.contains("NT-MANAGED-VIEW")) stages.incrementAndGet() } + host.connect(profile.path).use { conn -> + conn.listProjects() + val prepared = conn.prepareManagedSession(ManagedSessionChoice("server-custom", "shell", title = "Custom owned shell")) + assertEquals(profile.path, prepared.profile) + val receipt = conn.createManagedSession(prepared) + assertEquals("server-custom", receipt.projectId) + assertEquals("node-terminal", receipt.socket) + adoption = ManagedSessionAdoption(prepared, receipt) + val sink = Sink() + val stream = try { conn.attachManagedSession(adoption!!, 56, 25, sink) } + catch (e: HostException) { throw AssertionError("All three managed view stages must adopt the explicitly selected custom Server profile", e) } + try { + stream.write("printf 'custom_%s\\n' profile_input\r") + sink.waitFor("custom_profile_input") + assertFalse(stream.fresh) + assertEquals(receipt.panePid.toString(), host.tmux("display-message", "-p", "-t", receipt.paneId, "#{pane_pid}").second.trim()) + } finally { stream.detach() } + fixture.command("snapshot") + val proof = fixture.awaitEvent("snapshot") + assertEquals(1, proof.getValue("created").jsonArray.size, "one request creates exactly one actual pane") + assertTrue(proof.getValue("file").jsonObject.getValue("nodes").jsonArray.any { it.jsonObject.getValue("id").jsonPrimitive.content == receipt.nodeId }) + assertEquals(listOf("term-server-default"), proof.getValue("defaultFile").jsonObject.getValue("nodes").jsonArray.map { it.jsonObject.getValue("id").jsonPrimitive.content }) + } + assertEquals(3, stages.get(), "initial proof, actual viewer and matching tty acknowledgement all use custom selection") + host.onCommand = null + host.connect(profile.path).use { reopened -> + val sink = Sink(); val stream = reopened.attachManagedSession(adoption!!, 52, 20, sink) + try { stream.write("printf 'custom_%s\\n' reopened\r"); sink.waitFor("custom_reopened") } + finally { stream.detach() } + } + fixture.command("snapshot") + assertEquals(1, fixture.awaitEvent("snapshot").getValue("created").jsonArray.size, "reconnect never recreates the pane") + } + } + } + + @Test fun `missing explicit profile never falls back and loss clears custom actions before subsequent writes`() = runBlocking { + SshTestHost().use { host -> + val profile = File(host.home, "profiles/explicit") + serverProfileFixture(host, profile).use { fixture -> + host.connect(File(host.home, "missing explicit").path).use { absent -> + assertFailsWith { absent.listProjects() } + assertFalse(absent.capabilities.boardWrites) + } + host.connect(profile.path).use { conn -> + assertEquals(listOf("server-custom"), conn.listProjects().projects.map { it.id }) + assertTrue(conn.capabilities.boardWrites) + val held = File(host.home, "profiles/held") + assertTrue(profile.renameTo(held)) + try { + assertFailsWith { conn.listProjects() } + assertFalse(conn.capabilities.boardWrites) + assertFalse(conn.capabilities.managedCreate) + assertFailsWith { conn.ensureBoard("server-custom") } + assertFailsWith { conn.prepareManagedSession(ManagedSessionChoice("server-custom", "shell")) } + } finally { assertTrue(held.renameTo(profile)) } + assertEquals(listOf("server-custom"), conn.listProjects().projects.map { it.id }) + fixture.command("snapshot") + val proof = fixture.awaitEvent("snapshot") + assertNull(proof.getValue("file").jsonObject["kanban"]) + assertNull(proof.getValue("defaultFile").jsonObject["kanban"]) + assertTrue(proof.getValue("created").jsonArray.isEmpty()) + } + } + } + } + + /** Native tmux fixture facts are setup only; the product verifies them over actual SSH. */ + private fun managedAdoption(host: SshTestHost): ManagedSessionAdoption { + val ud = File(host.home, ".config/node-terminal").apply { mkdirs() } + val cwd = File(host.home, "managed-project").apply { mkdirs() } + val id = "term-managed-1234" + val creation = java.util.UUID.randomUUID().toString() + val instance = java.util.UUID.randomUUID().toString() + val ad = host.advertisement(instance) + val hidden = File(cwd, ".nodeterm").apply { mkdirs() } + File(hidden, "project.json").writeText(buildJsonObject { + put("version", 1); put("rev", 1); put("savedAt", "fixture"); put("name", "Managed") + put("color", "blue"); put("viewport", buildJsonObject { put("x", 0); put("y", 0); put("zoom", 1) }) + put("nodes", JsonArray(listOf(buildJsonObject { + put("id", id); put("kind", "terminal"); put("title", "Managed"); put("color", "blue"); put("cwd", cwd.path) + put("position", buildJsonObject { put("x", 0); put("y", 0) }); put("size", buildJsonObject { put("width", 640); put("height", 440) }) + }))) + }.toString()) + File(ud, "workspace.json").writeText(buildJsonObject { + put("version", 3); put("activeProjectId", "managed-project") + put("entries", JsonArray(listOf(buildJsonObject { put("id", "managed-project"); put("name", "Managed"); put("cwd", cwd.path); put("color", "blue") }))) + }.toString()) + val (exit, out) = host.tmux("-f", "/dev/null", "new-session", "-d", "-s", "nt-$id", "-c", cwd.path, + "-e", "NODETERM_MANAGED_CREATION_ID=$creation", *paneShell) + assertEquals(0, exit, out) + val identity = host.tmux("list-panes", "-s", "-t", "=nt-$id", "-F", "#{session_created}|#{pane_id}|#{pane_pid}").second.trim().split('|') + val pid = identity[2].toInt() + val birth = if (System.getProperty("os.name").startsWith("Linux")) { + val stat = File("/proc/$pid/stat").readText().substringAfterLast(')').trim().split(Regex("\\s+")) + "linux:${File("/proc/sys/kernel/random/boot_id").readText().trim()}:${stat[19]}" + } else { + val p = ProcessBuilder("ps", "-o", "lstart=", "-p", "$pid").apply { environment()["LC_ALL"] = "C" }.start() + val started = p.inputStream.bufferedReader().readText().trim().replace(Regex("\\s+"), " ") + assertEquals(0, p.waitFor()); "darwin:$started" + } + val request = PreparedManagedSession(ud.path, instance, java.util.UUID.randomUUID().toString(), ad.updatedAt, + creation, ManagedSessionChoice("managed-project", "shell"), ad.raw) + return ManagedSessionAdoption(request, ManagedSessionReceipt(creation, id, "managed-project", instance, + "node-terminal", "nt-$id", identity[1], pid, birth, identity[0])) + } + + @Test fun `managed receipt adopts the actual SSH viewer with output input and reconnect without creating again`() = runBlocking { + SshTestHost().use { host -> + val adoption = managedAdoption(host); val r = adoption.receipt + assertEquals(0, host.tmux("new-session", "-d", "-s", r.session + "-other", *paneShell).first) + host.connect().use { conn -> + assertTrue(conn.listProjects().findNode(r.nodeId) != null) + assertTrue(conn.capabilities.managedCreate); assertFalse(conn.capabilities.registerNode) + val sink = Sink(); val stream = conn.attachManagedSession(adoption, 56, 25, sink) + try { + assertFalse(stream.fresh, "host creation already delivered any agent launch") + stream.write("printf 'managed_%s\\n' input\r"); sink.waitFor("managed_input") + assertFalse(synchronized(sink.out) { sink.out.toString(Charsets.UTF_8) }.contains("NT-MANAGED-VIEW"), "private tty acknowledgement must not paint in xterm") + assertEquals(r.panePid.toString(), host.tmux("display-message", "-p", "-t", r.paneId, "#{pane_pid}").second.trim()) + } finally { stream.detach() } + } + host.connect().use { conn -> + val sink = Sink(); val stream = conn.attachManagedSession(adoption, 52, 20, sink) + stream.write("printf 'reopen_%s\\n' receipt\r"); sink.waitFor("reopen_receipt") + try { + assertTrue(assertFailsWith { stream.endSession() }.message.orEmpty().contains("relay")) + assertEquals(0, host.tmux("has-session", "-t", "=${r.session}").first, "canvas End still needs the app route; refusal does not stop the managed shell") + assertEquals(0, host.tmux("has-session", "-t", "=${r.session}-other").first, "refusal keeps the prefix neighbour") + } finally { stream.detach() } + } + } + } + + @Test fun `managed SSH adoption rejects stale process marker service profile and multi pane identities`() = runBlocking { + SshTestHost().use { host -> + val adoption = managedAdoption(host); val r = adoption.receipt + host.connect().use { conn -> + for (changed in listOf(r.copy(panePid = r.panePid + 1), r.copy(sessionCreated = (r.sessionCreated.toLong() + 1).toString()), + r.copy(paneBirth = if (r.paneBirth.startsWith("linux:")) r.paneBirth.substringBeforeLast(':') + ":0" else "darwin:Mon Jan 1 00:00:00 2000"))) { + assertFailsWith { conn.attachManagedSession(ManagedSessionAdoption(adoption.request, changed), 80, 24, Sink()) } + } + assertEquals("", host.tmux("list-clients", "-t", "=${r.session}").second.trim(), "no refused receipt acquires a viewer") + assertEquals(0, host.tmux("set-environment", "-t", "=${r.session}", "NODETERM_MANAGED_CREATION_ID", java.util.UUID.randomUUID().toString()).first) + assertFailsWith { conn.attachManagedSession(adoption, 80, 24, Sink()) } + assertEquals(0, host.tmux("set-environment", "-t", "=${r.session}", "NODETERM_MANAGED_CREATION_ID", r.creationId).first) + val pane = host.tmux("split-window", "-d", "-t", r.paneId, "-P", "-F", "#{pane_id}", *paneShell) + assertEquals(0, pane.first, pane.second) + assertFailsWith { conn.attachManagedSession(adoption, 80, 24, Sink()) } + assertEquals(0, host.tmux("kill-pane", "-t", pane.second.trim()).first) + host.advertisement(java.util.UUID.randomUUID().toString()) + assertFailsWith { conn.attachManagedSession(adoption, 80, 24, Sink()) } + host.advertisement(r.hostInstance) + val changedProfile = adoption.copy(request = adoption.request.copy(profile = File(host.home, "other-profile").path)) + assertFailsWith { conn.attachManagedSession(changedProfile, 80, 24, Sink()) } + assertEquals("", host.tmux("list-clients", "-t", "=${r.session}").second.trim()) + assertEquals(0, host.tmux("has-session", "-t", "=${r.session}").first, "refusal never kills the created shell") + } + } + } + + @Test fun `managed SSH final tmux queue refuses a marker replaced after shell proof`() = runBlocking { + SshTestHost().use { host -> + val adoption = managedAdoption(host); val r = adoption.receipt + val lookup = ProcessBuilder("/bin/sh", "-c", "command -v tmux").start() + val tmuxPath = lookup.inputStream.bufferedReader().readText().trim(); assertEquals(0, lookup.waitFor()) + val bin = File(host.home, "race-bin").apply { mkdirs() } + val replaced = java.util.UUID.randomUUID().toString() + val wrapper = File(bin, "tmux").apply { + writeText("""#!/bin/sh + case "${'$'}*" in *' if-shell '*) + ${SshScripts.q(tmuxPath)} -L node-terminal set-environment -t ${SshScripts.q("=" + r.session)} NODETERM_MANAGED_CREATION_ID ${SshScripts.q(replaced)} || exit 99 + ;; esac + exec ${SshScripts.q(tmuxPath)} "${'$'}@" + """.trimIndent()); assertTrue(setExecutable(true, true)) + } + host.commandPath = bin.path + File.pathSeparator + System.getenv("PATH") + val ad = assertNotNull(SshActions.parseAdvertisement("NT-ACTIONS-1\t${System.currentTimeMillis()}\n${adoption.request.advertisement}")) + val (exit, proof) = host.execute(SshScripts.attachManaged(adoption, ad, attach = false)) + assertTrue(exit != 0, "The final false branch must return a failed command: $proof") + assertFalse(proof.trim() == "NT-MANAGED-VERIFIED") + assertEquals(0, host.tmux("set-environment", "-t", "=${r.session}", "NODETERM_MANAGED_CREATION_ID", r.creationId).first) + host.connect().use { conn -> + assertFailsWith { conn.attachManagedSession(adoption, 56, 25, Sink()) } + assertEquals(replaced, host.tmux("show-environment", "-t", "=${r.session}", "NODETERM_MANAGED_CREATION_ID").second.trim().substringAfter('='), "the replacement happened between the shell proof and final queue") + assertEquals("", host.tmux("list-clients", "-t", "=${r.session}").second.trim()) + assertEquals(0, host.tmux("has-session", "-t", "=${r.session}").first) + host.commandPath = null + assertEquals(0, host.tmux("set-environment", "-t", "=${r.session}", "NODETERM_MANAGED_CREATION_ID", r.creationId).first) + val sink = Sink(); val stream = conn.attachManagedSession(adoption, 56, 25, sink) + try { stream.write("printf 'race_%s\\n' restored\r"); sink.waitFor("race_restored") } finally { stream.detach() } + } + assertTrue(wrapper.isFile) + } + } + + @Test fun `managed SSH viewer without an exit acknowledgement is not adopted or retried`() = runBlocking { + SshTestHost().use { host -> + val adoption = managedAdoption(host); val r = adoption.receipt; var confirmations = 0 + host.onCommand = { command, channel -> if (command.contains("nt_wait=0; nt_found=0")) { confirmations++; channel.omitExitStatus = true } } + host.connect().use { conn -> + assertFailsWith { conn.attachManagedSession(adoption, 56, 25, Sink()) } + assertEquals(1, confirmations, "only one viewer confirmation is attempted") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (host.tmux("list-clients", "-t", "=${r.session}").second.isNotBlank()) { + assertTrue(System.nanoTime() < deadline, "unacknowledged viewer stayed attached"); Thread.sleep(50) + } + assertEquals(r.panePid.toString(), host.tmux("display-message", "-p", "-t", r.paneId, "#{pane_pid}").second.trim()) + assertEquals(0, host.tmux("has-session", "-t", "=${r.session}").first) + } + } + } + + @Test fun `managed SSH attach cancelled during opening detaches only that viewer and retains the shell`() = runBlocking { + SshTestHost().use { host -> + val adoption = managedAdoption(host); val r = adoption.receipt + host.connect().use { conn -> + val attaching = AtomicReference(); val attachCommand = AtomicReference() + host.onCommand = { command, channel -> if (command.contains("NT-MANAGED-VIEW")) { attachCommand.set(channel); attaching.get().cancel() } } + val job = launch(Dispatchers.Default, start = CoroutineStart.LAZY) { conn.attachManagedSession(adoption, 56, 25, Sink()) } + attaching.set(job); job.start(); job.join(); assertTrue(job.isCancelled) + assertTrue(assertNotNull(attachCommand.get()).exited.await(10, TimeUnit.SECONDS)) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (host.tmux("list-clients", "-t", "=${r.session}").second.isNotBlank()) { + assertTrue(System.nanoTime() < deadline, "cancelled viewer stays attached"); Thread.sleep(50) + } + assertEquals(0, host.tmux("has-session", "-t", "=${r.session}").first) + } + } + } + + @Test fun `actual selected-profile SSH service edits the board and delivers node actions without relay`() = runBlocking { + SshTestHost().use { host -> + actionsFixture(host).use { fixture -> + host.connect().use { conn -> + val snapshot = conn.listProjects() + assertEquals(listOf("ssh-actions-project"), snapshot.projects.map { it.id }) + assertTrue(conn.capabilities.boardWrites); assertTrue(conn.capabilities.nodeActions); assertTrue(conn.capabilities.git) + assertFalse(conn.capabilities.registerNode) + val columns = assertNotNull(conn.ensureBoard("ssh-actions-project")) + assertEquals(3, columns.size) + assertTrue(conn.setCardColumn("ssh-actions-project", "term-ssh-actions", columns[1].id)) + val labels = assertNotNull(conn.editCardLabels("ssh-actions-project", "term-ssh-actions", + CardLabelEdit(create = listOf("SSH α ' quoted" to "blue")))) + assertTrue(labels.edited); assertEquals(listOf("SSH α ' quoted"), labels.labels.map { it.name }) + assertEquals(labels.labels.map { it.id }, labels.cardLabelIds) + conn.wake("term-ssh-actions"); conn.refresh("term-ssh-actions"); conn.rename("term-ssh-actions", "Typed α ' title") + fixture.command("snapshot") + val proof = fixture.awaitEvent("snapshot") + val board = proof.getValue("file").jsonObject.getValue("kanban").jsonObject + assertEquals(columns[1].id, board.getValue("assignments").jsonArray.single().jsonObject.getValue("columnId").jsonPrimitive.content) + assertEquals(labels.cardLabelIds, board.getValue("meta").jsonArray.single().jsonObject.getValue("labels").jsonArray.map { it.jsonPrimitive.content }) + assertEquals(listOf("wake", "refresh", "rename"), proof.getValue("nudges").jsonArray.map { it.jsonObject.getValue("method").jsonPrimitive.content }) + assertEquals("Typed α ' title", proof.getValue("nudges").jsonArray.last().jsonObject.getValue("title").jsonPrimitive.content) + assertFailsWith { conn.registerNode("ssh-actions-project", NewNode("new-node", null, null, null)) } + assertFailsWith { conn.setCardColumn("another-profile", "term-ssh-actions", null) } + assertFailsWith { conn.wake("term-another-profile") } + } + } + } + } + + @Test fun `stopped SSH actions producer clears cached capability before any request is published`() = runBlocking { + SshTestHost().use { host -> + actionsFixture(host).use { fixture -> + host.connect().use { conn -> + conn.listProjects(); assertTrue(conn.capabilities.boardWrites) + fixture.command("stop"); fixture.awaitEvent("stopped") + assertFailsWith { conn.ensureBoard("ssh-actions-project") } + assertFalse(conn.capabilities.boardWrites); assertTrue(conn.capabilities.git) + fixture.command("snapshot") + val file = fixture.awaitEvent("snapshot").getValue("file").jsonObject + assertNull(file["kanban"]) + } + } + } + } + + @Test fun `an SSH action with a lost exit acknowledgement is uncertain even when the save happened`() = runBlocking { + SshTestHost().use { host -> + actionsFixture(host).use { fixture -> + host.connect().use { conn -> + conn.listProjects() + var submissions = 0 + host.onCommand = { command, channel -> if (command.contains("NT-ACTIONS-REPLY")) { submissions++; channel.omitExitStatus = true } } + assertFailsWith { conn.ensureBoard("ssh-actions-project") } + assertEquals(1, submissions) + fixture.command("snapshot") + assertEquals(3, fixture.awaitEvent("snapshot").getValue("file").jsonObject.getValue("kanban").jsonObject.getValue("columns").jsonArray.size) + assertFalse(conn.capabilities.boardWrites) + } + } + } + } + + private fun tmuxAvailable() = runCatching { ProcessBuilder("tmux", "-V").start().waitFor() == 0 }.getOrDefault(false) + + /** A private raw-byte consumer distinguishes bracketed paste from Enter and timestamps both. */ + private fun composedRecorder(host: SshTestHost, pane: String, name: String): File { + val bytes = File(host.home, "$name.bytes") + val script = File(host.home, "$name.cjs").apply { writeText(""" + const fs = require('node:fs'); + const out = process.argv[2], b = Buffer.alloc(1); + process.stdout.write('\x1b[?2004h'); + fs.writeFileSync(out + '.ready', 'ready'); + while (true) { + const n = fs.readSync(0, b, 0, 1, null); if (!n) break; + fs.appendFileSync(out, b.subarray(0, n)); + fs.appendFileSync(out + '.times', JSON.stringify({ byte: b[0], at: Number(process.hrtime.bigint() / 1000000n) }) + '\n'); + } + """.trimIndent()) } + val command = "stty raw -echo; node ${SshScripts.q(script.path)} ${SshScripts.q(bytes.path)}" + assertEquals(0, host.tmux("send-keys", "-t", pane, "-l", "--", command).first) + assertEquals(0, host.tmux("send-keys", "-t", pane, "Enter").first) + val deadline = System.currentTimeMillis() + 5_000 + while (!File(bytes.path + ".ready").exists() && System.currentTimeMillis() < deadline) Thread.sleep(20) + assertTrue(File(bytes.path + ".ready").exists(), "private native raw consumer started") + // Readiness belongs to the consumer; allow tmux's parser to process its DEC2004 output. + // Exact observed delimiters below, rather than a nonportable format name, prove framing. + Thread.sleep(50) + return bytes + } + + private fun awaitComposedBytes(file: File, expected: String) { + val deadline = System.currentTimeMillis() + 5_000 + while ((!file.exists() || file.readText() != expected) && System.currentTimeMillis() < deadline) Thread.sleep(20) + assertEquals(expected, if (file.exists()) file.readText() else "", "actual pane input bytes") + } + + @Test fun `composed SSH Send exits emacs and vi history and separates real bracketed paste from Enter`() = runBlocking { + for (mode in listOf("emacs", "vi")) SshTestHost().use { host -> + val adoption = managedAdoption(host) + val pane = adoption.receipt.paneId + val bytes = composedRecorder(host, pane, "composed-$mode") + assertEquals(0, host.tmux("new-window", "-d", "-t", "=nt-${adoption.receipt.nodeId}", *paneShell).first) + val sibling = host.tmux("list-panes", "-s", "-t", "=nt-${adoption.receipt.nodeId}", "-F", "#{pane_id}|#{pane_pid}").second.lines().first { !it.startsWith("$pane|") }.trim() + val siblingPane = sibling.substringBefore('|') + val siblingBytes = composedRecorder(host, siblingPane, "sibling-$mode") + val siblingBefore = host.tmux("capture-pane", "-p", "-t", siblingPane, "-S", "-").second + host.connect().use { conn -> + val stream = conn.attach(adoption.receipt.nodeId, 80, 24, Sink()) + try { + assertEquals(0, host.tmux("set-option", "-w", "-t", pane, "mode-keys", mode).first) + assertEquals(0, host.tmux("copy-mode", "-t", pane).first) + assertEquals("1", host.tmux("display-message", "-p", "-t", pane, "#{pane_in_mode}").second.trim()) + assertEquals(ComposedInputResult.DELIVERED, stream.submitComposed(ComposedInput.Paste("full prompt\nΩ 😀\u001b[201~", true))) + val expected = "\u001b[200~full prompt\rΩ 😀[201~\u001b[201~\r" + awaitComposedBytes(bytes, expected) + assertEquals("0", host.tmux("display-message", "-p", "-t", pane, "#{pane_in_mode}").second.trim()) + val times = File(bytes.path + ".times").readLines().map { kotlinx.serialization.json.Json.parseToJsonElement(it).jsonObject } + val endAt = times[times.lastIndex - 1].getValue("at").jsonPrimitive.content.toLong() + val enterAt = times.last().getValue("at").jsonPrimitive.content.toLong() + assertTrue(enterAt - endAt >= 100, "paste delimiter and Enter must be separated, actual ${enterAt - endAt}ms") + println("composed native $mode: bytes=${expected.toByteArray().size} paste-to-enter=${enterAt - endAt}ms once; sibling untouched") + assertEquals(sibling, host.tmux("display-message", "-p", "-t", siblingPane, "#{pane_id}|#{pane_pid}").second.trim()) + assertEquals(siblingBefore, host.tmux("capture-pane", "-p", "-t", siblingPane, "-S", "-").second) + assertFalse(siblingBytes.exists(), "no input reached the sibling") + } finally { stream.detach() } + } + } + } + + @Test fun `composed SSH Ctrl is one raw byte with no paste or Enter`() = runBlocking { + SshTestHost().use { host -> + val adoption = managedAdoption(host); val pane = adoption.receipt.paneId + val bytes = composedRecorder(host, pane, "control") + host.connect().use { conn -> + val stream = conn.attachManagedSession(adoption, 80, 24, Sink()) + try { + assertEquals(0, host.tmux("copy-mode", "-t", pane).first) + assertEquals(ComposedInputResult.DELIVERED, stream.submitComposed(ComposedInput.Control("\u0003"))) + awaitComposedBytes(bytes, "\u0003") + } finally { stream.detach() } + } + } + } + + @Test fun `composed SSH refuses a replaced pane or changed selection without following either`() = runBlocking { + for (replace in listOf(false, true)) SshTestHost().use { host -> + val adoption = managedAdoption(host); val pane = adoption.receipt.paneId + val bytes = composedRecorder(host, pane, "old") + host.connect().use { conn -> + val stream = conn.attach(adoption.receipt.nodeId, 80, 24, Sink()) + try { + if (replace) assertEquals(0, host.tmux("respawn-pane", "-k", "-t", pane, *paneShell).first) + else { + assertEquals(0, host.tmux("new-window", "-d", "-t", "=nt-${adoption.receipt.nodeId}", *paneShell).first) + assertEquals(0, host.tmux("select-window", "-t", "=nt-${adoption.receipt.nodeId}:1").first) + } + assertEquals(ComposedInputResult.Status.REFUSED, stream.submitComposed(ComposedInput.Paste("must not arrive", true)).status) + assertFalse(bytes.exists(), "no bytes reached the old consumer") + assertFalse(host.tmux("capture-pane", "-p", "-t", "=nt-${adoption.receipt.nodeId}:", "-S", "-").second.contains("must not arrive")) + } finally { stream.detach() } + } + } + } + + @Test fun `composed SSH lost paste acknowledgement is uncertain once and never sends Enter or retries`() = runBlocking { + SshTestHost().use { host -> + val adoption = managedAdoption(host); val bytes = composedRecorder(host, adoption.receipt.paneId, "uncertain") + host.connect().use { conn -> + val stream = conn.attach(adoption.receipt.nodeId, 80, 24, Sink()); var submissions = 0 + host.onCommand = { command, channel -> if (command.contains("load-buffer") && command.contains("nt-composed-delivered")) { submissions++; channel.omitExitStatus = true } } + try { + assertEquals(ComposedInputResult.Status.UNCERTAIN, stream.submitComposed(ComposedInput.Paste("exactly once", true)).status) + awaitComposedBytes(bytes, "\u001b[200~exactly once\u001b[201~") + assertEquals(1, submissions); assertFalse(conn.isConnected) + } finally { host.onCommand = null; stream.detach() } + } + } + } + + @Test fun `composed SSH lost Enter acknowledgement is uncertain after one real paste and Enter without retry`() = runBlocking { + SshTestHost().use { host -> + val adoption = managedAdoption(host) + val bytes = composedRecorder(host, adoption.receipt.paneId, "lost-enter-ack") + host.connect().use { conn -> + val stream = conn.attach(adoption.receipt.nodeId, 80, 24, Sink()) + val pastes = java.util.concurrent.atomic.AtomicInteger() + val enters = java.util.concurrent.atomic.AtomicInteger() + val enterCommand = AtomicReference() + host.onCommand = { command, channel -> + if (command.contains("nt-composed-delivered")) { + if (command.contains("load-buffer")) pastes.incrementAndGet() + else { + enters.incrementAndGet() + enterCommand.set(channel) + // ShCommand executes the real guarded Enter and drains stdout before + // closing this channel without its exit-status acknowledgement. + channel.omitExitStatus = true + } + } + } + try { + val result = stream.submitComposed(ComposedInput.Paste("one Enter Ω🧭", true)) + awaitComposedBytes(bytes, "\u001b[200~one Enter Ω🧭\u001b[201~\r") + assertTrue(assertNotNull(enterCommand.get()).exited.await(2, TimeUnit.SECONDS), "the actual guarded Enter process exited") + assertEquals(1, pastes.get(), "one acknowledged paste; no retry") + assertEquals(1, enters.get(), "one executed Enter; no retry") + assertFalse(conn.isConnected, "the unconfirmed Enter disconnects the transport") + assertEquals(ComposedInputResult.Status.UNCERTAIN, result.status, "execution does not replace the missing second acknowledgement") + } finally { host.onCommand = null; stream.detach() } + } + } + } + + @Test fun `composed SSH queued behind raw IO is refused immediately on viewer retirement`() = runBlocking { + SshTestHost().use { host -> + val adoption = managedAdoption(host); val bytes = composedRecorder(host, adoption.receipt.paneId, "retired") + host.connect().use { conn -> + val stream = conn.attach(adoption.receipt.nodeId, 80, 24, Sink()) + val field = stream.javaClass.getDeclaredField("io").apply { isAccessible = true } + val io = field.get(stream) as java.util.concurrent.ExecutorService + val started = java.util.concurrent.CountDownLatch(1); val release = java.util.concurrent.CountDownLatch(1) + io.execute { started.countDown(); release.await(5, TimeUnit.SECONDS) } + assertTrue(started.await(2, TimeUnit.SECONDS)) + try { + val result = async(start = CoroutineStart.UNDISPATCHED) { stream.submitComposed(ComposedInput.Paste("retired", true)) } + yield(); stream.retireComposed(); release.countDown() + assertEquals(ComposedInputResult.Status.REFUSED, result.await().status) + assertFalse(bytes.exists(), "retirement did not wait for the queued detach") + } finally { release.countDown(); stream.detach() } + } + } + } + + @Test fun `composed SSH final native queue refuses a viewer detached after shell preflight`() = runBlocking { + SshTestHost().use { host -> + val adoption = managedAdoption(host); val bytes = composedRecorder(host, adoption.receipt.paneId, "late-detach") + host.connect().use { conn -> + val stream = conn.attach(adoption.receipt.nodeId, 80, 24, Sink()) + try { + val tty = host.tmux("list-clients", "-t", "=nt-${adoption.receipt.nodeId}", "-F", "#{client_name}").second.trim() + assertTrue(tty.startsWith("/dev/")) + val lookup = ProcessBuilder("/bin/sh", "-c", "command -v tmux").start() + val realTmux = lookup.inputStream.bufferedReader().readText().trim(); assertEquals(0, lookup.waitFor()) + val bin = File(host.home, "late-bin").apply { mkdirs() } + File(bin, "tmux").apply { + writeText(""" + #!/bin/sh + for nt_arg do + if [ "${'$'}nt_arg" = load-buffer ]; then + ${SshScripts.q(realTmux)} -L node-terminal detach-client -t ${SshScripts.q(tty)} || exit 3 + break + fi + done + exec ${SshScripts.q(realTmux)} "${'$'}@" + """.trimIndent()); setExecutable(true) + } + host.commandPath = bin.path + ":" + System.getenv("PATH") + assertTrue(stream.submitComposed(ComposedInput.Paste("must not arrive", true)).status != ComposedInputResult.Status.DELIVERED) + assertFalse(bytes.exists(), "the final native queue did not write after a valid preflight lost its viewer") + assertTrue(host.tmux("list-buffers", "-F", "#{buffer_name}").second.lines().none { it.startsWith("nt-paste-") }, "owned temporary buffer was removed") + } finally { host.commandPath = null; stream.detach() } + } + } + } + + @Test fun `composed SSH losing the viewer after acknowledged paste retains uncertainty without Enter`() = runBlocking { + SshTestHost().use { host -> + val adoption = managedAdoption(host); val bytes = composedRecorder(host, adoption.receipt.paneId, "paste-only") + host.connect().use { conn -> + val stream = conn.attach(adoption.receipt.nodeId, 80, 24, Sink()) + var enterAttempts = 0 + host.onCommand = { command, _ -> + if (command.contains("nt-composed-delivered") && !command.contains("load-buffer")) { + enterAttempts++ + assertEquals(0, host.tmux("new-window", "-t", "=nt-${adoption.receipt.nodeId}", *paneShell).first) + } + } + try { + assertEquals(ComposedInputResult.Status.UNCERTAIN, stream.submitComposed(ComposedInput.Paste("keep draft", true)).status) + awaitComposedBytes(bytes, "\u001b[200~keep draft\u001b[201~") + assertEquals(1, enterAttempts, "one guarded Enter attempt; no retry") + } finally { host.onCommand = null; stream.detach() } + } + } + } + + @Test fun `composed SSH refuses process birth drift even when numeric client server and pane IDs still match`() = runBlocking { + for (which in listOf("viewer", "server", "pane")) SshTestHost().use { host -> + val adoption = managedAdoption(host); val bytes = composedRecorder(host, adoption.receipt.paneId, "birth-$which") + host.connect().use { conn -> + val stream = conn.attach(adoption.receipt.nodeId, 80, 24, Sink()) + try { + val field = stream.javaClass.getDeclaredField("inputViewer").apply { isAccessible = true } + val original = field.get(stream) as SshInputViewer + fun drift(value: String) = if (value.startsWith("linux:")) value.substringBeforeLast(':') + ":" + (value.substringAfterLast(':').toLong() + 1) + else value.dropLast(1) + if (value.last() == '0') "1" else "0" + val changed = when (which) { + "viewer" -> original.copy(viewerBirth = drift(original.viewerBirth)) + "server" -> original.copy(serverBirth = drift(original.serverBirth)) + else -> original.copy(paneBirth = drift(original.paneBirth)) + } + // A lease whose PID was reused has this shape: all numeric tmux fields match, + // but its captured native birth belongs to another generation. + field.set(stream, changed) + assertEquals(ComposedInputResult.Status.REFUSED, stream.submitComposed(ComposedInput.Paste("must not arrive", true)).status, which) + assertFalse(bytes.exists(), which) + } finally { stream.detach() } + } + } + } + + // No locale at all, like an sshd exec channel on a stock macOS host (audit A03): passing the JVM's + // own LANG through is what hid that bug. + // Nor any NODETERM_* of the developer's (the review of A27a): the browse reads NODETERM_DATA_DIR, + // so an exported one pointed these tests at a real Server Edition's data dir. + private fun childEnv(home: File = this.home, tmuxDir: File = this.tmuxDir): Map = System.getenv().filterKeys { + it != "TMUX" && it != "TMUX_PANE" && it != "LANG" && it != "LANGUAGE" && !it.startsWith("LC_") && + !it.startsWith("NODETERM_") && it !in setOf("ENV", "BASH_ENV", "INPUTRC", "SHELLOPTS", "BASHOPTS") && + !it.startsWith("BASH_FUNC_") + } + + mapOf("HOME" to home.path, "TMUX_TMPDIR" to tmuxDir.path, "XDG_CONFIG_HOME" to File(home, ".config").path, "SHELL" to "/bin/sh") + + private fun tmux(vararg args: String): Pair = tmuxOn("node-terminal", *args) + + /** tmux on [socket] — inside this class's private TMUX_TMPDIR, never a developer's server (#629). */ + private fun tmuxOn(socket: String, vararg args: String): Pair { + val pb = ProcessBuilder(listOf("tmux", "-L", socket) + args).redirectErrorStream(true) + pb.environment().clear() + pb.environment().putAll(childEnv()) + val p = pb.start() + val out = p.inputStream.bufferedReader().readText() + return p.waitFor() to out + } + + /** Called with each command the server is asked to run, before it runs (and before sshj hears back). */ + @Volatile private var onCommand: ((String, ShCommand) -> Unit)? = null + + private inner class ShCommand( + private val command: String, + private val commandHome: File = home, + private val environment: Map = childEnv() + ) : Command { + private lateinit var input: InputStream + private lateinit var output: OutputStream + private lateinit var error: OutputStream + private lateinit var exit: ExitCallback + private var process: Process? = null + /** Counted down once the command's process has exited (for a pty, `script` and what it ran). */ + val exited = java.util.concurrent.CountDownLatch(1) + /** A server that closes the channel without confirming the command's exit status. */ + var omitExitStatus = false + + override fun setInputStream(`in`: InputStream) { input = `in` } + override fun setOutputStream(out: OutputStream) { output = out } + override fun setErrorStream(err: OutputStream) { error = err } + override fun setExitCallback(callback: ExitCallback) { exit = callback } + + override fun start(channel: ChannelSession, env: Environment) { + val cols = env.env["COLUMNS"] ?: "80" + val lines = env.env["LINES"] ?: "24" + val pty = env.env.containsKey("TERM") + val argv = if (pty) ptyScript.argv("stty cols $cols rows $lines 2>/dev/null; $command") + else listOf("/bin/sh", "-c", command) + val pb = ProcessBuilder(argv).directory(commandHome) + pb.environment().clear() + pb.environment().putAll(environment) + val p = pb.start() + process = p + fun pump(from: InputStream, to: OutputStream, closeTo: Boolean) = Thread { + runCatching { + val buf = ByteArray(8192) + while (true) { + val n = from.read(buf) + if (n < 0) break + to.write(buf, 0, n) + to.flush() + } + } + if (closeTo) runCatching { to.close() } + }.apply { isDaemon = true; start() } + val outPump = pump(p.inputStream, output, false) + val errPump = pump(p.errorStream, error, false) + // For a pty session, channel EOF must NOT become stdin EOF: `script` turns that into a ^D + // typed into the pty (util-linux measured; FreeBSD's source does the same), which ends the + // pane's shell — a harness artifact real sshd does not have (it closes the pty and the tmux + // client just detaches). + pump(input, p.outputStream, !pty) + Thread { + val code = p.waitFor() + outPump.join(2000) + errPump.join(2000) + exited.countDown() + if (omitExitStatus) channel.close(false) else exit.onExit(code) + }.apply { isDaemon = true; start() } + } + + override fun destroy(channel: ChannelSession) { + process?.destroy() + } + } + + @BeforeAll + fun setUp() { + assumeTrue(tmuxAvailable(), "${PtyScript.SKIP_REASON}; no tmux on PATH") + val script = PtyScript.detect() + assumeTrue(script != null, "${PtyScript.SKIP_REASON}; `script` answered neither form") + ptyScript = script!! + // Short and resolved, so the tmux socket fits in sun_path on a Mac too (audit A62). + root = ShortTmuxRoot.create("nt-ssh", "tmux", "node-terminal") + home = File(root, "home").apply { mkdirs() } + tmuxDir = File(root, "tmux").apply { mkdirs() } + server = SshServer.setUpDefaultServer() + server.host = "127.0.0.1" + server.port = 0 + server.keyPairProvider = SimpleGeneratorHostKeyProvider(File(root, "hostkey.ser").toPath()) + val expected = identity.keyPair.public.encoded + server.publickeyAuthenticator = org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator { user, key, _ -> + authAttempts.incrementAndGet() + user == "dev" && key.encoded.contentEquals(expected) + } + server.commandFactory = org.apache.sshd.server.command.CommandFactory { _, command -> + ShCommand(command).also { onCommand?.invoke(command, it) } + } + server.start() + port = server.port + layOutDesktop() + } + + @AfterAll + fun tearDown() { + if (!::server.isInitialized) return + runCatching { tmux("kill-server") } + runCatching { stopRmtServer() } + runCatching { tmuxOn(TmuxNames.PHONE_SOCKET, "kill-server") } + server.stop(true) + root.deleteRecursively() + } + + @BeforeEach + fun resetPrimaryPane() { + // Tests deliberately send Escape, leave partial input and alter PATH. Even a failed test + // must not leave those bytes or shell settings for the next test in this shared session. + val (code, out) = tmux("respawn-pane", "-k", "-t", "=nt-term-a-1:", "-c", File(repo, "sub").path, *paneShell) + assertEquals(0, code, out) + val token = "ready_${System.nanoTime()}" + assertEquals(0, tmux("send-keys", "-t", "=nt-term-a-1:", "-l", "--", "printf 'fixture_%s\\n' '$token'").first) + assertEquals(0, tmux("send-keys", "-t", "=nt-term-a-1:", "Enter").first) + assertTrue(waitForPane("node-terminal", "nt-term-a-1", "fixture_$token").contains("fixture_$token"), "the fresh shell must be ready") + } + + private val repo get() = File(root, "repo") + + private fun layOutDesktop() { + // The REAL directory name (audit A02): Electron's userData is package.json `name`. + val ud = File(home, ".config/node-terminal").apply { mkdirs() } + File(repo, ".nodeterm").mkdirs() + File(repo, ".nodeterm/project.json").writeText( + """{"version":1,"rev":3,"savedAt":"x","name":"file name ignored","color":"#000", + "viewport":{"x":0,"y":0,"zoom":1}, + "nodes":[{"id":"term-a-1","kind":"terminal","title":"Claude","color":"#d97757","group":null, + "agentId":"claude","cwd":"./sub","position":{"x":0,"y":0},"size":{"width":1,"height":1}}], + "kanban":{"columns":[{"id":"c1","title":"Doing","color":"#0a84ff"}],"assignments":[{"nodeId":"term-a-1","columnId":"c1"}]}}""" + ) + File(ud, "inline-projects").mkdirs() + File(ud, "inline-projects/p3.json").writeText( + """{"version":1,"rev":2,"savedAt":"x","name":"n","color":"#000","viewport":{"x":0,"y":0,"zoom":1}, + "nodes":[{"id":"term-c-3","kind":"terminal","title":"From file","color":"#000","group":null,"position":{"x":0,"y":0},"size":{"width":1,"height":1}}]}""" + ) + File(ud, "workspace.json").writeText( + """{"version":3,"activeProjectId":"p1","entries":[ + {"id":"p1","name":"Repo","color":"#0a84ff","cwd":"${repo.path}"}, + {"id":"p2","name":"Server","color":"#ff9f0a","ssh":{"server":{"host":"box","user":"me"},"remoteCwd":"~"}, + "cache":{"version":1,"rev":1,"savedAt":"x","name":"n","color":"#000","viewport":{"x":0,"y":0,"zoom":1}, + "nodes":[{"id":"term-b-2","kind":"terminal","title":"Remote","color":"#000","group":null,"position":{"x":0,"y":0},"size":{"width":1,"height":1}}]}}, + {"id":"p3","name":"Scratch","color":"#bf5af2","dataFile":true, + "project":{"id":"p3","name":"Scratch","color":"#bf5af2","viewport":{"x":0,"y":0,"zoom":1}, + "nodes":[{"id":"term-c-3","kind":"terminal","title":"Stale cache","color":"#000","group":null,"position":{"x":0,"y":0},"size":{"width":1,"height":1}}]}}, + {"id":"p4","name":"Parked","color":"#8e8e93","closed":true,"project":{"id":"p4","name":"Parked","color":"#8e8e93","viewport":{"x":0,"y":0,"zoom":1},"nodes":[]}} + ]}""" + ) + File(ud, "agent-status.json").writeText( + """{"v":1,"updatedAt":1,"nodes":{"term-a-1":{"state":"working","agentId":"claude","updatedAt":5}}, + "inbox":{"events":[],"nodes":{}}}""" + ) + File(ud, "tmux.conf").writeText("set -g status off\n") + File(repo, "sub").mkdirs() + val (code, out) = tmux("-f", File(ud, "tmux.conf").path, "new-session", "-d", "-s", "nt-term-a-1", "-c", File(repo, "sub").path, *paneShell) + assertEquals(0, code, out) + } + + private fun connect(pin: HostKeyPin = MemoryPin(), factory: javax.net.SocketFactory? = null) = + SshHostConnection.connect("127.0.0.1", port, "dev", identity, pin, socketFactory = factory) + + private fun gitRepo(): File { + val dir = File(repo, "git-${System.nanoTime()}").apply { mkdirs() } + gitAt(dir, "init", "-b", "main") + gitAt(dir, "config", "user.name", "SSH fixture") + gitAt(dir, "config", "user.email", "fixture@example.invalid") + gitAt(dir, "config", "commit.gpgsign", "false") + File(dir, "base").writeText("initial\n") + gitAt(dir, "add", "--", "base") + gitAt(dir, "commit", "-m", "initial") + return dir + } + + private fun gitAt(dir: File, vararg args: String): String { + val pb = ProcessBuilder(listOf("git") + args).directory(dir).redirectErrorStream(true) + pb.environment().clear(); pb.environment().putAll(childEnv()) + val process = pb.start(); val out = process.inputStream.bufferedReader().readText() + assertEquals(0, process.waitFor(), out) + return out + } + + @Test + fun `typed Git over real SSH serves status diff stage unstage commit push pull and history`() = runBlocking { + val dir = gitRepo() + val remote = File(root, "remote-${System.nanoTime()}.git") + gitAt(dir, "init", "--bare", "-b", "main", remote.path) + gitAt(dir, "remote", "add", "origin", remote.path) + try { + connect().use { conn -> + assertTrue(conn.capabilities.git) + assertEquals(LegRouting.Leg.Primary, LegRouting.route(Capability.GIT, conn.kind, conn.capabilities, LegRouting.RelayLeg.ADDED_OVER_SSH)) + // Git itself triggers the first authoritative browse before any jailed command. + val control = SourceControl(conn, File(dir, "sub").apply { mkdirs() }.path) + val name = "-literal ' \$(touch injected)\nß.txt" + File(dir, name).writeText("first\nsecond\n") + val file = control.status().untracked.single() + assertEquals(name, file.path) + assertTrue(control.diff(file, staged = false).lines.any { it.text == "+second" }) + assertTrue(control.stage(listOf(name)).ok) + assertEquals(name, control.status().staged.single().path) + assertTrue(control.unstage(listOf(name)).ok) + assertEquals(name, control.status().untracked.single().path) + assertTrue(control.stage(listOf(name)).ok) + assertTrue(control.commit("from Android ' \$(touch injected)").ok) + assertFalse(File(dir, "injected").exists()) + assertEquals(2, control.history().commits.size) + assertTrue(control.push().ok) + assertTrue(control.status().hasUpstream) + assertEquals("origin/main", control.history().remoteRef) + File(dir, "base").appendText("rejected\n") + gitAt(dir, "commit", "-am", "rejected") + val attempts = File(remote, "attempts") + val hook = File(remote, "hooks/pre-receive").apply { + writeText("#!/bin/sh\nprintf '%s\\n' attempt >> ${SshScripts.q(attempts.path)}\nprintf '%s\\n' 'set-upstream: no upstream; rejected by fixture' >&2\nexit 1\n") + setExecutable(true) + } + assertFalse(control.push().ok) + assertEquals(1, attempts.readText().split("attempt").size - 1, "a matching remote rejection is not a second SSH Git push") + hook.delete(); gitAt(dir, "reset", "--hard", "HEAD~1") + gitAt(dir, "reset", "--hard", "HEAD~1") + assertTrue(control.history().hasIncomingChanges) + assertTrue(control.pull().ok) + assertTrue(File(dir, name).exists()) + } + } finally { dir.deleteRecursively(); remote.deleteRecursively() } + } + + @Test + fun `Git jail never treats third-machine caches as local folders and rechecks symlinks`() = runBlocking { + val dir = gitRepo() + val outside = File(root, "outside-${System.nanoTime()}").apply { mkdirs() } + gitAt(outside, "init", "-b", "main") + val link = File(repo, "link-${System.nanoTime()}") + java.nio.file.Files.createSymbolicLink(link.toPath(), outside.toPath()) + val workspace = File(home, ".config/node-terminal/workspace.json") + val before = workspace.readText() + try { + val index = kotlinx.serialization.json.Json.parseToJsonElement(before) as kotlinx.serialization.json.JsonObject + val entries = (index["entries"] as kotlinx.serialization.json.JsonArray).map { value -> + val entry = value as kotlinx.serialization.json.JsonObject + if ((entry["id"] as? JsonPrimitive)?.content != "p2") entry else { + val cache = entry["cache"] as kotlinx.serialization.json.JsonObject + kotlinx.serialization.json.JsonObject(entry + ("cache" to kotlinx.serialization.json.JsonObject(cache + ("cwd" to JsonPrimitive(outside.path))))) + } + } + workspace.writeText(kotlinx.serialization.json.JsonObject(index + ("entries" to kotlinx.serialization.json.JsonArray(entries))).toString()) + connect().use { conn -> + val third = conn.listProjects().projects.single { it.id == "p2" } + assertNotNull(third.sshTarget) + assertEquals(outside.path, third.cwd, "a real folder cached for another machine is not a local root") + for (cwd in listOf(outside.path, link.path, "/srv/not-a-local-project", "/")) { + val refused = assertFailsWith { conn.git(GitVerb.STATUS, cwd) } + assertTrue(refused.message!!.contains("listed project") || refused.message!!.contains("cannot be opened"), refused.message) + } + assertFailsWith { conn.git(GitVerb.STAGE, dir.path, mapOf("paths" to kotlinx.serialization.json.JsonArray(listOf(JsonPrimitive("../../outside"))))) } + assertTrue(GitReplies.status(conn.git(GitVerb.STATUS, dir.path))!!.hasRepo) + } + } finally { workspace.writeText(before); link.delete(); outside.deleteRecursively(); dir.deleteRecursively() } + } + + @Test + fun `SSH-only driven projects serve Git on this machine without changing shared canvas files`() = runBlocking { + try { + layOutDrivenHost(System.currentTimeMillis()) + gitAt(remoteRepo, "init", "-b", "main") + gitAt(remoteRepo, "config", "user.name", "SSH fixture") + gitAt(remoteRepo, "config", "user.email", "fixture@example.invalid") + gitAt(remoteRepo, "config", "commit.gpgsign", "false") + val canvas = File(remoteRepo, ".nodeterm/project.json").readBytes() + withoutOwnData { + runBlocking { + connect().use { conn -> + val project = conn.listProjects().projects.single { it.id == "project-drv" } + assertTrue(project.drivenRemotely); assertNull(project.sshTarget) + val leg = LegRouting.forProject(Capability.GIT, project, + LegRouting.route(Capability.GIT, conn.kind, conn.capabilities, LegRouting.RelayLeg.ADDED_OVER_SSH)) + assertEquals(LegRouting.Leg.Primary, leg) + assertIs(dev.nodeterm.protocol.git.SourceControlGate.of(project, leg)) + File(remoteRepo, "owned.txt").writeText("local driven repository\n") + val control = SourceControl(conn, project.cwd!!) + assertTrue(control.status().untracked.any { it.path == "owned.txt" }) + assertTrue(control.stage(listOf("owned.txt")).ok) + assertTrue(control.commit("direct driven Git").ok) + assertEquals("direct driven Git", control.history().commits.first().subject) + assertTrue(canvas.contentEquals(File(remoteRepo, ".nodeterm/project.json").readBytes())) + } + } + } + } finally { clearDrivenHost() } + } + + @Test + fun `an SSH Git write without an exit receipt is uncertain once and never automatically repeated`() = runBlocking { + val dir = gitRepo() + val writes = java.util.concurrent.atomic.AtomicInteger() + try { + connect().use { conn -> + conn.listProjects() + File(dir, "new").writeText("new\n") + val control = SourceControl(conn, dir.path) + assertTrue(control.stage(listOf("new")).ok) + onCommand = { command, sh -> + if (command.contains("'commit'")) { writes.incrementAndGet(); sh.omitExitStatus = true } + } + val unknown = assertFailsWith { control.commit("unanswered-but-done") } + assertTrue(unknown.message!!.contains("may still be running there, or may have finished"), unknown.message) + assertEquals(1, writes.get()) + assertFalse(conn.isConnected, "an unconfirmed write retires this transport before another action") + assertEquals("unanswered-but-done", gitAt(dir, "log", "-1", "--format=%s").trim()) + } + } finally { onCommand = null; dir.deleteRecursively() } + } + + @Test + fun `SSH write deadline tears down transport and retains uncertain outcome`() = runBlocking { + val marker = File(root, "write-${System.nanoTime()}") + try { + connect().use { conn -> + assertFailsWith { + conn.run("printf done > ${SshScripts.q(marker.path)}; sleep 3", timeoutSec = 1, uncertainWrite = true) + } + assertEquals("done", marker.readText()) + assertFalse(conn.isConnected) + } + } finally { marker.delete() } + } + + @Test + fun `SSH Git output cap retires the channel and an oversized dispatched write stays uncertain`() = runBlocking { + for (write in listOf(false, true)) connect().use { conn -> + val error = assertFailsWith { + conn.run("printf '%s' 'a bounded fixture output bigger than eight bytes'", uncertainWrite = write, outputLimit = 8) + } + if (write) assertIs(error) else assertFalse(error is HostUnansweredException) + assertTrue(error.message!!.contains("output exceeded"), error.message) + assertFalse(conn.isConnected) + } + } + + /** + * Android's StrictMode, reproduced on the JVM (which has no BlockGuard): a socket whose streams + * throw a RuntimeException — like `NetworkOnMainThreadException` — when used from a thread marked + * as "main". The JVM tests could not see audit A01/A04 without this. + */ + private class MainThreadGuard : javax.net.SocketFactory() { + val main = ThreadLocal.withInitial { false } + val sockets = java.util.Collections.synchronizedList(ArrayList()) + val violations = java.util.concurrent.atomic.AtomicInteger() + @Volatile var poisonAll = false + @Volatile var rejectNoDelay = false + + private fun check() { + if (poisonAll) throw IllegalStateException("simulated failure after the cipher advanced") + if (main.get()) { + violations.incrementAndGet() + throw IllegalStateException("NetworkOnMainThreadException (simulated)") + } + } + + private fun guarded(): java.net.Socket = object : java.net.Socket() { + override fun setTcpNoDelay(on: Boolean) { + if (on && rejectNoDelay) throw java.net.SocketException("TCP_NODELAY configuration refused") + super.setTcpNoDelay(on) + } + + override fun getOutputStream(): OutputStream { + val real = super.getOutputStream() + return object : OutputStream() { + override fun write(b: Int) { check(); real.write(b) } + override fun write(b: ByteArray, off: Int, len: Int) { check(); real.write(b, off, len) } + override fun flush() { check(); real.flush() } + override fun close() = real.close() + } + } + }.also { sockets.add(it) } + + override fun createSocket(): java.net.Socket = guarded() + override fun createSocket(host: String, port: Int) = guarded().apply { connect(java.net.InetSocketAddress(host, port)) } + override fun createSocket(host: String, port: Int, l: java.net.InetAddress, lp: Int) = createSocket(host, port) + override fun createSocket(host: java.net.InetAddress, port: Int) = guarded().apply { connect(java.net.InetSocketAddress(host, port)) } + override fun createSocket(a: java.net.InetAddress, p: Int, l: java.net.InetAddress, lp: Int) = createSocket(a, p) + + fun onMain(block: () -> T): T { + main.set(true) + try { return block() } finally { main.set(false) } + } + } + + /** + * What a screen shows for [e] when the phone has no relay leg to open (A27): no relay offered, and + * the reason that is actually in the way for that leg (the review of A27b). + */ + private fun assertNoRelayPromised(e: NeedsRelayException) { + assertTrue(e.withoutRelay.contains("Remote access isn't set up for this computer"), e.withoutRelay) + assertNull(e.refusal(LegRouting.RelayLeg.AVAILABLE), "a usable relay leg is offered, not refused") + for (leg in LegRouting.RelayLeg.entries - LegRouting.RelayLeg.AVAILABLE) { + val said = assertNotNull(e.refusal(leg), "$leg") + assertTrue(said.startsWith(e.fact), "$leg: $said") + assertFalse(said.contains("opens through the relay"), "$leg: $said") + } + assertEquals(e.withoutRelay, e.refusal(LegRouting.RelayLeg.ADDED_OVER_SSH)) + // A paired computer set to "Only on my network" HAS remote access: the refusal names the route + // setting in the way, never "isn't set up" (what InboxTab and SessionsTab used to say). + val sshOnly = e.refusal(LegRouting.RelayLeg.ROUTE_SSH_ONLY)!! + assertTrue(sshOnly.contains("\"Only on my network (SSH)\"") && sshOnly.contains("How to reach each computer"), sshOnly) + assertFalse(sshOnly.contains("isn't set up"), sshOnly) + for (leg in listOf(LegRouting.RelayLeg.NOT_PICKED_UP, LegRouting.RelayLeg.REMOTE_ACCESS_OFF)) { + assertFalse(e.refusal(leg)!!.contains("isn't set up"), "$leg: ${e.refusal(leg)}") + } + } + + private class MemoryPin(var value: String? = null, private val paired: List = emptyList()) : HostKeyPin { + override fun pinned() = value + override fun pin(fingerprint: String) { + value = fingerprint + } + override fun anchors() = paired + } + + private class Sink : TerminalSink { + val out = ByteArrayOutputStream() + override fun onPaint(text: String) {} + override fun onOutput(bytes: ByteArray) { + synchronized(out) { out.write(bytes) } + } + override fun onExit(code: Int?) {} + fun waitFor(needle: String, ms: Long = 8_000) { + val end = System.currentTimeMillis() + ms + while (!synchronized(out) { out.toString(Charsets.UTF_8) }.contains(needle)) { + if (System.currentTimeMillis() > end) throw AssertionError("'$needle' never appeared: ${synchronized(out) { out.toString(Charsets.UTF_8) }.takeLast(400)}") + Thread.sleep(50) + } + } + } + + @Test + fun `a fresh test pane drops pending input and shell startup settings`() { + val init = File(home, "unexpected-shell-init.sh").apply { writeText("echo startup_leaked\n") } + try { + assertEquals(0, tmux("set-environment", "-g", "ENV", init.path).first) + assertEquals(0, tmux("send-keys", "-t", "=nt-term-a-1:", "-l", "--", "PATH=/no-test-commands; export PATH").first) + assertEquals(0, tmux("send-keys", "-t", "=nt-term-a-1:", "Enter").first) + assertEquals(0, tmux("send-keys", "-t", "=nt-term-a-1:", "-l", "--", "echo leftover_").first) + assertEquals(0, tmux("send-keys", "-t", "=nt-term-a-1:", "Escape").first) + resetPrimaryPane() + assertEquals(0, tmux("send-keys", "-t", "=nt-term-a-1:", "-l", "--", "[ -x /bin/sh ] && command -v sh >/dev/null && printf 'isolated_%s\\n' yes").first) + assertEquals(0, tmux("send-keys", "-t", "=nt-term-a-1:", "Enter").first) + val pane = waitForPane("node-terminal", "nt-term-a-1", "isolated_yes") + assertTrue(pane.contains("isolated_yes"), pane) + assertFalse(pane.contains("leftover_"), pane) + assertFalse(pane.contains("startup_leaked"), pane) + } finally { + tmux("set-environment", "-gu", "ENV") + init.delete() + } + } + + @Test + fun `browse resolves the v3 index the way the desktop assembles it`() = runBlocking { + connect().use { conn -> + val snap = conn.listProjects() + assertEquals(listOf("Repo", "Server", "Scratch", "Parked"), snap.projects.map { it.name }, "names come from the ENTRY") + val repoProject = snap.projects[0] + val node = repoProject.nodes.single() + assertEquals("term-a-1", node.id) + assertEquals(repo.path + "/sub", node.cwd, "portable ./ cwds resolve against the folder") + assertEquals("c1", repoProject.board!!.columnOf("term-a-1")) + assertEquals("me@box", snap.projects[1].sshTarget) + assertEquals(listOf("term-b-2"), snap.projects[1].nodes.map { it.id }, "an ssh ref reads its offline cache") + assertEquals(listOf("From file"), snap.projects[2].nodes.map { it.title }, "the data file wins over the cache") + assertTrue(snap.projects[3].closed) + assertTrue(snap.isLive("term-a-1")) + assertFalse(snap.isLive("term-b-2")) + assertEquals(AgentState.WORKING, snap.statusOf("term-a-1")!!.state) + } + } + + @Test + fun `attach joins the live tmux session and carries keystrokes both ways`() = runBlocking { + connect().use { conn -> + conn.listProjects() + val sink = Sink() + val stream = conn.attach("term-a-1", 100, 30, sink) + assertFalse(stream.fresh) + Thread.sleep(400) + stream.write("echo nt_\$((6*7))\r") + sink.waitFor("nt_42") + stream.detach() + Thread.sleep(300) + val (code, out) = tmux("has-session", "-t", "=nt-term-a-1") + assertEquals(0, code, "detaching never ends the session: $out / ${tmux("ls").second}") + } + } + + @Test + fun `SSH scrolling reveals history produced before the phone attached`() = runBlocking { + val pane = "=nt-term-a-1:" + val marker = "history_before_attach_${System.nanoTime()}" + // The desktop owns scrolling through tmux's mouse mode. The general SSH fixture only + // disables the status bar, so enable that production setting for this session explicitly. + val mouse = tmux("set-option", "-t", pane, "mouse", "on") + assertEquals(0, mouse.first, mouse.second) + try { + assertEquals(0, tmux("clear-history", "-t", pane).first) + val command = "printf '$marker\\n'; i=1; while [ \"${'$'}i\" -le 60 ]; do " + + "printf 'history_tail_%03d\\n' \"${'$'}i\"; i=${'$'}((i+1)); done" + assertEquals(0, tmux("send-keys", "-t", pane, "-l", "--", command).first) + assertEquals(0, tmux("send-keys", "-t", pane, "Enter").first) + waitForPane("node-terminal", "nt-term-a-1", "history_tail_060") + + connect().use { conn -> + val sink = Sink() + val stream = conn.attach("term-a-1", 100, 30, sink) + try { + sink.waitFor("history_tail_060") + assertFalse(synchronized(sink.out) { sink.out.toString(Charsets.UTF_8) }.contains(marker), + "the old marker must be above the initial screen, not replayed on attach") + val history = tmux("display-message", "-p", "-t", pane, "#{history_size}").second.trim().toInt() + assertTrue(history > 0, "the host retained the output produced before attach") + + stream.scroll(up = true, lines = 20) + sink.waitFor(marker) + assertEquals("1", tmux("display-message", "-p", "-t", pane, "#{pane_in_mode}").second.trim(), + "the phone's wheel-up must enter tmux copy mode") + val position = tmux("display-message", "-p", "-t", pane, "#{scroll_position}").second.trim().toInt() + assertTrue(position > 0, "the phone is viewing older output") + + stream.scroll(up = false, lines = 20) + val deadline = System.currentTimeMillis() + 8_000 + var after = position + while (after >= position && System.currentTimeMillis() < deadline) { + Thread.sleep(50) + after = tmux("display-message", "-p", "-t", pane, "#{scroll_position}") + .second.trim().toIntOrNull() ?: 0 // copy mode may close at the live bottom + } + assertTrue(after < position, "wheel-down must return toward live output: $position -> $after") + } finally { + stream.detach() + } + } + } finally { + tmux("send-keys", "-X", "-t", pane, "cancel") + val resetMouse = tmux("set-option", "-u", "-t", pane, "mouse") + assertEquals(0, resetMouse.first, resetMouse.second) + } + } + + @Test + fun `SSH scrolling honors custom wheel bindings in both copy key tables`() = runBlocking { + val pane = "=nt-term-a-1:" + fun checkedTmux(vararg args: String): String { + val (code, out) = tmux(*args) + assertEquals(0, code, "tmux ${args.joinToString(" ")}: $out") + return out.trimEnd() + } + fun cancelCopyMode() { + val active = checkedTmux("display-message", "-p", "-t", pane, "#{pane_in_mode}") + assertTrue(active == "0" || active == "1", "missing native pane_in_mode: $active") + if (active == "1") checkedTmux("send-keys", "-X", "-t", pane, "cancel") + } + fun localOption(window: Boolean, name: String): String? = + checkedTmux(*(if (window) arrayOf("show-options", "-w", "-t", pane) + else arrayOf("show-options", "-t", pane))).lineSequence() + .singleOrNull { it.startsWith("$name ") }?.substringAfter(' ') + fun wheelBindings(table: String): List = checkedTmux("list-keys", "-T", table) + .lineSequence().filter { + Regex("^bind-key(?:\\s+-r)?\\s+-T\\s+${Regex.escape(table)}\\s+Wheel(?:Up|Down)Pane(?:\\s|$)") + .containsMatchIn(it) + }.toList() + + data class Mode(val keys: String, val table: String, val upGain: Int, val downGain: Int) + val modes = listOf(Mode("emacs", "copy-mode", 3, 2), Mode("vi", "copy-mode-vi", 7, 4)) + val originalMouse = localOption(window = false, "mouse") + val originalModeKeys = localOption(window = true, "mode-keys") + val originalBindings = modes.associate { it.table to wheelBindings(it.table) } + // list-keys emits tmux source syntax. Save only the affected private-server bindings and + // local overrides, including absence, so a failed assertion cannot change another test. + val restore = File.createTempFile("custom-wheel-restore-", ".conf", home) + restore.writeText(buildString { + appendLine(if (originalMouse == null) "set-option -u -t $pane mouse" + else "set-option -t $pane mouse $originalMouse") + appendLine(if (originalModeKeys == null) "set-option -u -w -t $pane mode-keys" + else "set-option -w -t $pane mode-keys $originalModeKeys") + for (mode in modes) { + appendLine("unbind-key -T ${mode.table} WheelUpPane") + appendLine("unbind-key -T ${mode.table} WheelDownPane") + originalBindings.getValue(mode.table).forEach { appendLine(it) } + } + }) + try { + checkedTmux("set-option", "-t", pane, "mouse", "on") + checkedTmux("clear-history", "-t", pane) + val marker = "custom_wheel_done_${System.nanoTime()}" + val command = "i=1; while [ \"${'$'}i\" -le 750 ]; do " + + "printf 'custom_wheel_%04d\\n' \"${'$'}i\"; i=${'$'}((i+1)); done; printf '$marker\\n'" + checkedTmux("send-keys", "-t", pane, "-l", "--", command) + checkedTmux("send-keys", "-t", pane, "Enter") + waitForPane("node-terminal", "nt-term-a-1", marker) + + connect().use { conn -> + val sink = Sink() + val stream = conn.attach("term-a-1", 100, 30, sink) + val actions = TerminalActions(this, stream) { true } + try { + sink.waitFor(marker) + val history = checkedTmux("display-message", "-p", "-t", pane, "#{history_size}").toInt() + assertTrue(history > 400, "all exact positions must stay below the retained-history boundary") + fun position(): Int { + val state = checkedTmux("display-message", "-p", "-t", pane, "#{pane_in_mode}|#{scroll_position}").split('|') + assertEquals("1", state.first(), "custom wheel scrolling must remain in copy mode") + return assertNotNull(state.getOrNull(1)?.toIntOrNull(), "missing native scroll_position: $state") + } + suspend fun awaitPosition(mode: Mode, stage: String, expected: Int) { + yield() // Dispatch the actor's queued calls before measuring the native pane. + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(8) + var actual = position() + var stableSince: Long? = null + while (System.nanoTime() < deadline) { + val now = System.nanoTime() + if (actual == expected) { + if (stableSince == null) stableSince = now + if (now - stableSince >= TimeUnit.MILLISECONDS.toNanos(300)) break + } else stableSince = null + delay(25) // Let TerminalActions drain; SSH writes also finish asynchronously. + actual = position() + } + assertEquals(expected, actual, "${mode.keys} $stage (up=${mode.upGain}, down=${mode.downGain})") + assertTrue(stableSince != null && System.nanoTime() - stableSince >= TimeUnit.MILLISECONDS.toNanos(300), + "${mode.keys} $stage must settle, not briefly cross the expected position") + println("custom-wheel native ${mode.keys} $stage: upGain=${mode.upGain} downGain=${mode.downGain} scroll_position=$actual expected=$expected stableMs=300") + } + for (mode in modes) { + cancelCopyMode() + checkedTmux("set-option", "-w", "-t", pane, "mode-keys", mode.keys) + checkedTmux("bind-key", "-T", mode.table, "WheelUpPane", "send-keys", "-X", "-N", mode.upGain.toString(), "scroll-up") + checkedTmux("bind-key", "-T", mode.table, "WheelDownPane", "send-keys", "-X", "-N", mode.downGain.toString(), "scroll-down") + checkedTmux("copy-mode", "-t", pane) + assertEquals(0, position()) + + // Each call exceeds SSH's 20-wheel limit: TerminalActions must deliver + // every notch, while the host's own asymmetric bindings define row gain. + assertTrue(actions.scroll(up = true, notches = 37)) + awaitPosition(mode, "up37", 37 * mode.upGain) + assertTrue(actions.scroll(up = false, notches = 23)) + awaitPosition(mode, "down23", 37 * mode.upGain - 23 * mode.downGain) + assertTrue(actions.scroll(up = true, notches = 29)) + awaitPosition(mode, "up29", 66 * mode.upGain - 23 * mode.downGain) + + cancelCopyMode() + checkedTmux("copy-mode", "-t", pane) + assertEquals(0, position()) + // Queue both reversals before yielding. Down73 reaches the live bottom; + // its clamp makes the final position sensitive to FIFO direction order. + assertTrue(actions.scroll(up = true, notches = 37)) + assertTrue(actions.scroll(up = false, notches = 73)) + assertTrue(actions.scroll(up = true, notches = 29)) + awaitPosition(mode, "queued up37 down73 up29", 29 * mode.upGain) + } + } finally { + actions.close() + stream.detach() + } + } + } finally { + try { + cancelCopyMode() + } finally { + try { + checkedTmux("source-file", restore.path) + assertEquals(originalMouse, localOption(window = false, "mouse")) + assertEquals(originalModeKeys, localOption(window = true, "mode-keys")) + modes.forEach { assertEquals(originalBindings.getValue(it.table), wheelBindings(it.table)) } + } finally { + restore.delete() + } + } + } + } + + @Test + fun `an attach cancelled while it opens leaves no tmux client behind`() = runBlocking { + // A40 review: the blocking open finishes even when its caller is cancelled meanwhile, and + // withContext then drops the stream. Nothing held it, so its tmux client stayed attached for + // the life of the connection. The cancel lands exactly while the server starts the attach. + connect().use { conn -> + conn.listProjects() + val attaching = AtomicReference() + val attachCommand = AtomicReference() + onCommand = { command, cmd -> + if (command.contains("attach-session")) { + attachCommand.set(cmd) + attaching.get().cancel() + } + } + try { + val job = launch(Dispatchers.Default, start = CoroutineStart.LAZY) { conn.attach("term-a-1", 100, 30, Sink()) } + attaching.set(job) + job.start() + job.join() + assertTrue(job.isCancelled) + val cmd = assertNotNull(attachCommand.get(), "the attach reached the server") + assertTrue(cmd.exited.await(10, TimeUnit.SECONDS), "the stream nobody holds was closed") + val deadline = System.currentTimeMillis() + 5_000 + while (tmux("list-clients", "-t", "=nt-term-a-1").second.isNotBlank()) { + if (System.currentTimeMillis() > deadline) throw AssertionError("a tmux client is still attached: ${tmux("list-clients").second}") + Thread.sleep(100) + } + } finally { + onCommand = null + } + assertEquals(0, tmux("has-session", "-t", "=nt-term-a-1").first, "letting go never ends the session") + } + } + + @Test + fun `the connected SSH socket disables packet coalescing for low latency input`() { + connect().use { conn -> + // Observe the actual default-factory native socket after a real SSH handshake/auth. + val field = SshHostConnection::class.java.getDeclaredField("client").apply { isAccessible = true } + val client = field.get(conn) as net.schmizz.sshj.SSHClient + assertTrue(client.socket.isConnected) + assertTrue(client.socket.tcpNoDelay, "small input packets must not wait for an earlier packet's ACK") + } + } + + @Test + fun `low latency input also uses the socket supplied by a custom factory`() = runBlocking { + val factory = MainThreadGuard() + connect(factory = factory).use { conn -> + val socket = factory.sockets.single() + assertTrue(socket.isConnected) + assertTrue(socket.tcpNoDelay, "the custom factory's real connected socket must also disable Nagle") + assertTrue(conn.listProjects().projects.isNotEmpty(), "the configured socket still serves SSH commands") + } + } + + @Test + fun `a refused low latency socket option closes the socket before authentication or pinning`() { + val factory = MainThreadGuard().apply { rejectNoDelay = true } + val pin = MemoryPin() + val before = authAttempts.get() + try { + val failure = assertFailsWith { connect(pin, factory) } + assertTrue(failure.message.orEmpty().contains("TCP_NODELAY configuration refused")) + assertTrue(factory.sockets.single().isClosed, "socket option failure must not leak the connected socket") + assertEquals(before, authAttempts.get(), "a socket configuration failure must precede authentication") + assertNull(pin.value, "a failed connection must not pin the host key") + } finally { + factory.sockets.forEach { runCatching { it.close() } } + } + } + + @Test + fun `resizes and key chips from the main thread never touch the socket there, and keep order`() = runBlocking { + // A01/A04: before the fix the resize threw on the "main" thread after sshj had advanced its + // cipher state, and the next packet dropped the connection. + val guard = MainThreadGuard() + val conn = connect(factory = guard) + var closedReason: String? = null + conn.setOnClosed { closedReason = it ?: "closed" } + try { + conn.listProjects() + val sink = Sink() + val stream = conn.attach("term-a-1", 100, 30, sink) + Thread.sleep(400) + guard.onMain { + stream.resize(90, 28) + stream.write("echo ma") + stream.write("in_\$((5*5))\r") + stream.resize(100, 30) + } + sink.waitFor("main_25") + // The connection is still usable for other work (listing, the Inbox) afterwards. + conn.listProjects() + assertEquals(0, guard.violations.get(), "no socket I/O ran on the main thread") + assertEquals(null, closedReason) + stream.detach() + } finally { + guard.onMain { conn.close() } + } + // close() from the main thread still really closes the socket (it used to leak). + val end = System.currentTimeMillis() + 5_000 + while (guard.sockets.any { !it.isClosed } && System.currentTimeMillis() < end) Thread.sleep(50) + assertTrue(guard.sockets.all { it.isClosed }, "close() from the main thread leaked the socket") + assertEquals(0, guard.violations.get()) + } + + @Test + fun `a write failure that corrupts the transport closes the connection instead of hiding it`() = runBlocking { + val guard = MainThreadGuard() + val conn = connect(factory = guard) + val closed = java.util.concurrent.CountDownLatch(1) + conn.setOnClosed { closed.countDown() } + try { + conn.listProjects() + val stream = conn.attach("term-a-1", 100, 30, Sink()) + Thread.sleep(300) + // Every socket write now fails with a RuntimeException, on whatever thread it runs — + // including the stream's own writer: the transport must be torn down, not ignored. + guard.poisonAll = true + stream.write("x") + assertTrue(closed.await(10, TimeUnit.SECONDS), "the broken transport was reported through onClosed") + // sshj's own disconnect listener may report first; the socket close lands right after. + val end = System.currentTimeMillis() + 5_000 + while (conn.isConnected && System.currentTimeMillis() < end) Thread.sleep(20) + assertFalse(conn.isConnected) + } finally { + guard.poisonAll = false + conn.close() + } + } + + @Test + fun `non-ASCII survives the attach even when the host sets no locale`() = runBlocking { + connect().use { conn -> + val sink = Sink() + val stream = conn.attach("term-a-1", 100, 30, sink) + Thread.sleep(400) + // ╭ (no ACS mapping) and é, built from octal escapes so the INPUT is pure ASCII. + stream.write("printf 'u8:\\342\\225\\255\\303\\251:end\\n'\r") + sink.waitFor("u8:╭é:end") + stream.detach() + } + } + + @Test + fun `a command that never answers drops the connection instead of hanging`() = runBlocking { + // A31: the read had no deadline, so a peer that vanished mid-command blocked it for as long + // as TCP took to give up. A hung command stands in for the vanished peer here. + val conn = connect() + val closed = java.util.concurrent.CountDownLatch(1) + conn.setOnClosed { closed.countDown() } + val t0 = System.currentTimeMillis() + assertFailsWith { conn.run("sleep 30", timeoutSec = 1) } + assertTrue(System.currentTimeMillis() - t0 < 10_000, "the deadline bounded the call") + assertTrue(closed.await(5, TimeUnit.SECONDS), "the drop was reported, so the owner can fall back") + conn.close() + } + + @Test + fun `phone terminal creates at home with no desktop data, browses, reattaches and ends independently`() = runBlocking { + val id = "phone-" + java.util.UUID.randomUUID() + try { + withoutOwnData { + runBlocking { + connect().use { conn -> + assertFailsWith { conn.listProjects() } + conn.createTerminal(id) + val snap = conn.listProjects() + val p = snap.projects.single { it.id == PhoneTerminals.PROJECT_ID } + assertEquals(home.path, p.nodes.single().cwd) + assertEquals(TmuxNames.PHONE_SOCKET, snap.socketOf(id)) + assertTrue(snap.isLive(id)) + val sink = Sink() + val stream = conn.attach(id, 52, 12, sink) + assertFalse(stream.fresh, "explicit creation does not invoke cold-agent launch") + stream.write("printf 'phone_%s\\n' home\r") + sink.waitFor("phone_home") + stream.detach() + assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "has-session", "-t", "=nt-$id").first) + } + connect().use { conn -> + val snap = conn.listProjects() + assertEquals(home.path, snap.findNode(id)!!.second.cwd) + val stream = conn.attach(id, 56, 25, Sink()) + stream.endSession() + assertFailsWith { conn.listProjects() } + val ended = assertFailsWith { conn.attach(id, 80, 24, Sink()) } + assertFalse(ended is NeedsRelayException) + assertEquals(0, tmux("has-session", "-t", "=nt-term-a-1").first, "other sockets are untouched") + } + } + } + } finally { tmuxOn(TmuxNames.PHONE_SOCKET, "kill-server") } + } + + @Test + fun `phone terminal quotes hostile folder names and retries the same creation without respawning`() = runBlocking { + val id = "phone-" + java.util.UUID.randomUUID() + val folder = File(root, "it's a \$(touch phone-injected) folder").apply { mkdirs() } + try { + connect().use { conn -> + conn.createTerminal(id, folder.path) + val target = "=nt-$id:" + val pid = tmuxOn(TmuxNames.PHONE_SOCKET, "display-message", "-p", "-t", target, "#{pane_pid}").second.trim() + conn.createTerminal(id, folder.path) + assertEquals(pid, tmuxOn(TmuxNames.PHONE_SOCKET, "display-message", "-p", "-t", target, "#{pane_pid}").second.trim()) + assertEquals(folder.path, conn.listProjects().findNode(id)!!.second.cwd) + assertFalse(File(root, "phone-injected").exists()) + assertFailsWith { conn.createTerminal(id, home.path) } + assertEquals(folder.path, conn.listProjects().findNode(id)!!.second.cwd) + assertFailsWith { conn.createTerminal("phone-" + java.util.UUID.randomUUID(), File(root, "gone").path) } + val renamed = File(root, "renamed-phone-folder") + assertTrue(folder.renameTo(renamed)) + try { + conn.createTerminal(id, folder.path) + assertEquals(pid, tmuxOn(TmuxNames.PHONE_SOCKET, "display-message", "-p", "-t", target, "#{pane_pid}").second.trim()) + assertEquals(folder.path, conn.listProjects().findNode(id)!!.second.cwd, "retry retains original metadata after directory rename") + } finally { assertTrue(renamed.renameTo(folder)) } + } + } finally { tmuxOn(TmuxNames.PHONE_SOCKET, "kill-server"); folder.deleteRecursively() } + } + + @Test + fun `phone terminal uses driven project folder without changing its canvas or cold-agent refusal`() = runBlocking { + val id = "phone-" + java.util.UUID.randomUUID() + try { + layOutDrivenHost(System.currentTimeMillis()) + val file = File(remoteRepo, ".nodeterm/project.json") + val before = file.readBytes() + withoutOwnData { + runBlocking { + connect().use { conn -> + val driven = conn.listProjects().projects.first { it.id == "project-drv" } + assertTrue(driven.drivenRemotely) + conn.createTerminal(id, driven.cwd) + val snap = conn.listProjects() + assertEquals(remoteRepo.path, snap.findNode(id)!!.second.cwd) + assertEquals(TmuxNames.PHONE_SOCKET, snap.socketOf(id)) + assertTrue(before.contentEquals(file.readBytes()), "the driving desktop owns this canvas") + val cold = assertFailsWith { conn.attach("term-r-3", 80, 24, Sink()) } + assertFalse(cold is NeedsRelayException) + assertEquals(1, tmuxOn(TmuxNames.REMOTE_SOCKET, "has-session", "-t", "=nt-term-r-3").first) + val sink = Sink() + val stream = conn.attach(id, 52, 20, sink) + stream.write("printf 'driven_%s\\n' folder\r") + sink.waitFor("driven_folder") + stream.detach() + } + } + } + } finally { tmuxOn(TmuxNames.PHONE_SOCKET, "kill-server"); clearDrivenHost() } + } + + @Test + fun `phone terminal foreign collisions and stale ownership never attach type or kill`() = runBlocking { + val id = "phone-" + java.util.UUID.randomUUID() + val target = "=nt-$id:" + try { + assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "-f", "/dev/null", "new-session", "-d", "-s", "nt-$id", *paneShell).first) + connect().use { conn -> + assertFailsWith { conn.createTerminal(id, home.path) } + val refused = assertFailsWith { conn.attach(id, 80, 24, Sink()) } + assertFalse(refused is NeedsRelayException) + assertFailsWith { conn.sendKeys(id, "must_not_arrive\r") } + assertFailsWith { conn.killSession(id) } + assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "has-session", "-t", "=nt-$id").first) + assertFalse(tmuxOn(TmuxNames.PHONE_SOCKET, "capture-pane", "-p", "-t", target).second.contains("must_not_arrive")) + } + tmuxOn(TmuxNames.PHONE_SOCKET, "kill-session", "-t", "=nt-$id") + // A completed listing must not authorize a target whose ownership changed afterwards. + Thread.sleep(150) + connect().use { conn -> + conn.createTerminal(id, home.path) + assertTrue(conn.listProjects().isLive(id)) + assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "set-option", "-t", target, PhoneTerminals.CREATION_OPTION, "wrong").first) + assertFailsWith { conn.sendKeys(id, "stale_write\r") } + assertFailsWith { conn.killSession(id) } + assertFailsWith { conn.createTerminal(id, home.path) } + assertFalse(conn.listProjects().isLive(id)) + } + } finally { tmuxOn(TmuxNames.PHONE_SOCKET, "kill-server") } + } + + @Test + fun `phone terminal malformed atomic tuples and fully replaced stale markers cannot authorize actions`() = runBlocking { + val id = "phone-" + java.util.UUID.randomUUID() + val target = "=nt-$id:" + val wrong = "a".repeat(64) + try { + assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "-f", "/dev/null", "new-session", "-d", "-s", "nt-$id", + "-e", "${PhoneTerminals.ID_ENV}=$id", "-e", "${PhoneTerminals.CWD_ENV}=${home.path}", + "-e", "${PhoneTerminals.REQUEST_ENV}=${home.path}", "-e", "${PhoneTerminals.CREATION_ENV}=$wrong", *paneShell).first) + connect().use { conn -> + assertFalse(conn.listProjects().isLive(id)) + assertFailsWith { conn.attach(id, 80, 24, Sink()) } + assertFailsWith { conn.sendKeys(id, "forged_write\r") } + assertFailsWith { conn.killSession(id) } + assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "has-session", "-t", "=nt-$id").first) + } + tmuxOn(TmuxNames.PHONE_SOCKET, "kill-session", "-t", "=nt-$id") + Thread.sleep(150) + connect().use { conn -> + conn.createTerminal(id, home.path) + assertTrue(conn.listProjects().isLive(id)) + assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "set-environment", "-t", "=nt-$id", PhoneTerminals.CREATION_ENV, wrong).first) + assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "set-option", "-t", target, PhoneTerminals.CREATION_OPTION, wrong).first) + // Options still agree with the environment; this connection pins the valid tuple + // from its last listing, so replacing both cannot authorize writes or deletion. + assertFailsWith { conn.attach(id, 80, 24, Sink()) } + assertFailsWith { conn.sendKeys(id, "replaced_write\r") } + assertNull(conn.paneCommand(id)) + assertFailsWith { conn.killSession(id) } + assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "has-session", "-t", "=nt-$id").first) + assertFalse(tmuxOn(TmuxNames.PHONE_SOCKET, "capture-pane", "-p", "-t", target).second.contains("replaced_write")) + assertFalse(conn.listProjects().isLive(id)) + } + } finally { tmuxOn(TmuxNames.PHONE_SOCKET, "kill-server") } + } + + @Test + fun `phone terminal reserved id cannot create or reopen on either desktop socket`() = runBlocking { + for (socket in listOf(TmuxNames.SOCKET, TmuxNames.REMOTE_SOCKET)) { + val id = "phone-" + java.util.UUID.randomUUID() + try { + assertEquals(0, tmuxOn(socket, "-f", "/dev/null", "new-session", "-d", "-s", "nt-$id", *paneShell).first) + connect().use { conn -> + assertFailsWith { conn.createTerminal(id, home.path) } + assertFailsWith { conn.attach(id, 80, 24, Sink()) } + assertEquals(1, tmuxOn(TmuxNames.PHONE_SOCKET, "has-session", "-t", "=nt-$id").first) + assertEquals(0, tmuxOn(socket, "has-session", "-t", "=nt-$id").first) + } + } finally { tmuxOn(socket, "kill-session", "-t", "=nt-$id") } + } + } + + @Test + fun `phone terminal warm server clears all stale managed hook identities and has configured history`() = runBlocking { + val id = "phone-" + java.util.UUID.randomUUID() + try { + assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "-f", "/dev/null", "new-session", "-d", "-s", "warm", *paneShell).first) + for (name in listOf("NODETERM_NODE_ID", "NODETERM_HOOK_TOKEN", "NODETERM_FUTURE_ID", "CLAUDE_CONFIG_DIR", "CODEX_HOME")) { + assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "set-environment", "-g", name, "fixture-stale-value").first) + } + for (name in listOf("LANG", "LC_ALL")) assertEquals(0, tmuxOn(TmuxNames.PHONE_SOCKET, "set-environment", "-g", name, "C").first) + connect().use { conn -> + conn.createTerminal(id, home.path) + val target = "=nt-$id:" + assertEquals("50000", tmuxOn(TmuxNames.PHONE_SOCKET, "display-message", "-p", "-t", target, "#{history_limit}").second.trim()) + val sink = Sink() + val stream = conn.attach(id, 52, 10, sink) + stream.write("n=\$(env | grep '^NODETERM_' | wc -l | tr -d ' '); printf 'cleared_%s\\n' \"\$n\"\r") + sink.waitFor("cleared_0") + stream.write("printf 'accounts_%s_%s\\n' \"\${CLAUDE_CONFIG_DIR:-none}\" \"\${CODEX_HOME:-none}\"\r") + sink.waitFor("accounts_none_none") + stream.write("printf 'phone_locale_%s\\n' \"\$(locale charmap)\"\r") + sink.waitFor("phone_locale_UTF-8") + stream.write("i=1; while [ \$i -le 80 ]; do printf 'phone_history_%s\\n' \"\$i\"; i=\$((i+1)); done\r") + sink.waitFor("phone_history_80") + stream.scroll(true, 20) + val modeDeadline = System.currentTimeMillis() + 5000 + while (System.currentTimeMillis() < modeDeadline && tmuxOn(TmuxNames.PHONE_SOCKET, "display-message", "-p", "-t", target, "#{pane_in_mode}").second.trim() != "1") Thread.sleep(20) + assertEquals("1", tmuxOn(TmuxNames.PHONE_SOCKET, "display-message", "-p", "-t", target, "#{pane_in_mode}").second.trim()) + conn.sendKeys(id, "\u001b") + assertEquals("0", tmuxOn(TmuxNames.PHONE_SOCKET, "display-message", "-p", "-t", target, "#{pane_in_mode}").second.trim()) + stream.detach() + } + } finally { tmuxOn(TmuxNames.PHONE_SOCKET, "kill-server") } + } + + @Test + fun `phone terminal interrupted option finalization is discoverable after a process restart`() = runBlocking { + val id = "phone-" + java.util.UUID.randomUUID() + val target = "=nt-$id:" + try { + // Stop the actual generated shell immediately after its new-session command returns. + // No metadata options or viewer attach have run; only the atomic creation tuple exists. + val script = SshScripts.createTerminal(id, home.path) + val partial = script.substringBefore("nt_marker=") + "exit 99\n" + assertTrue(partial.contains("new-session")) + connect().use { conn -> assertEquals(99, conn.run(partial).first) } + assertEquals("", tmuxOn(TmuxNames.PHONE_SOCKET, "display-message", "-p", "-t", target, "#{${PhoneTerminals.CREATION_OPTION}}").second.trim()) + assertEquals("50000", tmuxOn(TmuxNames.PHONE_SOCKET, "display-message", "-p", "-t", target, "#{history_limit}").second.trim(), + "startup output already gets the configured history before option finalization or attach") + connect().use { conn -> + val snap = conn.listProjects() + assertTrue(snap.isLive(id), "restart must rediscover an owned creation, not orphan it") + assertEquals(home.path, snap.findNode(id)!!.second.cwd) + val sink = Sink() + val stream = conn.attach(id, 52, 20, sink) + stream.write("printf 'recovered_%s\\n' partial\r") + sink.waitFor("recovered_partial") + assertEquals("on", tmuxOn(TmuxNames.PHONE_SOCKET, "show-options", "-v", "-t", target, "mouse").second.trim()) + stream.endSession() + assertEquals(1, tmuxOn(TmuxNames.PHONE_SOCKET, "has-session", "-t", "=nt-$id").first) + } + } finally { tmuxOn(TmuxNames.PHONE_SOCKET, "kill-server") } + } + + @Test + fun `phone terminal a lost exit status preserves ownership for an idempotent retry`() = runBlocking { + val id = "phone-" + java.util.UUID.randomUUID() + try { + connect().use { conn -> + onCommand = { command, cmd -> if (command.contains("new-session") && command.contains(id)) cmd.omitExitStatus = true } + val failed = assertFailsWith { conn.createTerminal(id, home.path) } + assertFalse(failed is SshTerminalCreationRefusedException, "unknown outcome must preserve the request") + onCommand = null + val target = "=nt-$id:" + val pid = tmuxOn(TmuxNames.PHONE_SOCKET, "display-message", "-p", "-t", target, "#{pane_pid}").second.trim() + assertTrue(pid.toLongOrNull() != null) + conn.createTerminal(id, home.path) + assertEquals(pid, tmuxOn(TmuxNames.PHONE_SOCKET, "display-message", "-p", "-t", target, "#{pane_pid}").second.trim()) + assertTrue(conn.listProjects().isLive(id)) + assertEquals("", tmuxOn(TmuxNames.PHONE_SOCKET, "list-clients", "-t", "=nt-$id").second.trim(), "creation itself reserves no viewer") + } + } finally { onCommand = null; tmuxOn(TmuxNames.PHONE_SOCKET, "kill-server") } + } + + @Test + fun `a session that is not running is never created over SSH`() = runBlocking { + // A08: `new-session -A` over SSH created the desktop's session with no hook env. Now the + // phone is told to use the relay, and nothing appears on the computer's tmux. term-c-3 is a + // node of the computer's own index (the Scratch project) whose session is not running. + connect().use { conn -> + conn.listProjects() + val e = assertFailsWith { conn.attach("term-c-3", 80, 24, Sink()) } + assertEquals("term-c-3", e.nodeId) + // With no relay leg to offer (remote access off, or a computer added by its SSH address, + // A27), the refusal promises no relay and says remote access isn't set up. + assertNoRelayPromised(e) + Thread.sleep(300) + assertEquals(1, tmux("has-session", "-t", "=nt-term-c-3").first, "no session was created") + } + } + + @Test + fun `a node no listing names is not offered this computer's relay when its session is not running`() = runBlocking { + // The review of A27a: the relay's pty.attach creates what it does not find, so for a node the + // computer's own index does not have (a deleted node, a driven project no longer listed) it + // would make a bare nt- of no project on node-terminal. Only the computer's own nodes get + // the relay; this one is told why it cannot be started from here. + connect().use { conn -> + val e = assertFailsWith { conn.attach("term-z-9", 80, 24, Sink()) } + assertFalse(e is NeedsRelayException, "no relay offered for a node nobody lists") + assertEquals(SshHostConnection.NOT_RUNNING_UNLISTED, e.message) + Thread.sleep(300) + assertEquals(1, tmux("has-session", "-t", "=nt-term-z-9").first, "no session was created") + } + } + + @Test + fun `a fresh connection settles which nodes are whose before it refuses, with no listing first`() = runBlocking { + // The review of A27a: what a node is (the desktop's SSH project's, a driven one, the computer's + // own) came only from a listing on the SAME connection, and a redial or a terminal restored + // after the process died attaches at once. Each call below is the first on its connection. + connect().use { conn -> + // A09 with no listing: still the desktop's SSH project's node, not this computer's tmux. + val e = assertFailsWith { conn.attach("term-b-2", 80, 24, Sink()) } + assertTrue(e.message!!.contains("me@box"), e.message) + assertEquals(1, tmux("has-session", "-t", "=nt-term-b-2").first, "no phantom session") + } + connect().use { conn -> + val ev = InboxEvent("e2", 1, "term-b-2", "claude", null, InboxKind.APPROVAL, "Approve", null, false, false, emptyList(), false, "term-b-2-1-1") + assertFailsWith { conn.answerApproval(ev, allow = true) } + } + connect().use { conn -> + conn.ackRead("term-b-2", "e2") + assertFalse(File(home, ".nodeterm/acks/term-b-2.seen").exists(), "no ack written on the wrong machine") + } + connect().use { conn -> + // The computer's own node that is not running: the relay, as with a listing. + assertFailsWith { conn.attach("term-c-3", 80, 24, Sink()) } + } + } + + @Test + fun `a Sleeping session opened over SSH offers its wake line, which a tap types at the pane's own prompt (A76)`() = runBlocking { + // Eco exited the CLI: term-a-1's pane is a shell again, still in the node's folder, and the + // mirror says Sleeping. Nothing tells the desktop about an SSH attach, so the phone offers it. + val status = File(home, ".config/node-terminal/agent-status.json") + val before = status.readText() + // A stand-in CLI that reports where it ran and with what. + val bin = File(root, "fake-bin").apply { mkdirs() } + File(bin, "claude").apply { + writeText("#!/bin/sh\necho \"woke:\$(pwd -P):\$*\"\n") + setExecutable(true) + } + status.writeText( + """{"v":1,"updatedAt":1,"nodes":{"term-a-1":{"agentId":"claude","sessionId":"sid-7","hibernated":true,"updatedAt":5}}, + "settings":{"claudePermissionMode":"plan","claudeAccounts":[]},"inbox":{"events":[],"nodes":{}}}""" + ) + try { + connect().use { conn -> + val snap = conn.listProjects() + val stream = conn.attach("term-a-1", 100, 30, Sink()) + assertTrue(ResumeOffer.wantsPane(stream.fresh, conn.kind, snap, "term-a-1")) + val owner = conn.paneCommand("term-a-1") + assertTrue(Pane.isShell(owner), "Eco's shell owns the pane: $owner") + val offer = assertNotNull(ResumeOffer.afterAttach(stream.fresh, conn.kind, snap, "term-a-1", paneCommand = owner)) + assertEquals(ResumeOffer.Kind.WAKE, offer.kind) + assertEquals("claude --resume sid-7 --permission-mode plan", offer.command, "no cd: the pane is already there") + Thread.sleep(400) + stream.write("PATH='${bin.path}':\$PATH; export PATH\r") + // A line someone left half-typed at the Sleeping prompt: the wake clears it first, + // or the shell would run `echo half_typedclaude …` and the CLI would never start. + stream.write("echo half_typed") + stream.write(offer.keys) + val want = "woke:${File(repo, "sub").canonicalPath}:--resume sid-7 --permission-mode plan" + val end = System.currentTimeMillis() + 8_000 + var pane = "" + while (System.currentTimeMillis() < end) { + pane = tmux("capture-pane", "-p", "-J", "-t", "=nt-term-a-1:").second + if (pane.contains(want)) break + Thread.sleep(100) + } + assertTrue(pane.contains(want), pane) + stream.detach() + } + } finally { + status.writeText(before) + } + } + + /** + * The A76 review: the mirror's `hibernated` flag outlives a CLI resumed outside the desktop's own + * wake (here the desktop app is not running at all, so nothing hears the resumed CLI). The phone + * wakes a Sleeping codex session, codex keeps running, and the node still reads Sleeping; the next + * open must not offer `codex resume` into it, where it would be sent as a prompt. + */ + @Test + fun `a codex session the phone woke is not offered the wake again while it runs, though the flag stays`() = runBlocking { + val status = File(home, ".config/node-terminal/agent-status.json") + val before = status.readText() + // term-a-1 runs codex for this test. + val project = File(repo, ".nodeterm/project.json") + val projectBefore = project.readText() + project.writeText(projectBefore.replace("\"agentId\":\"claude\"", "\"agentId\":\"codex\"")) + // A stand-in codex that stays in the foreground, like the real TUI (its argv[0] is not a shell). + val bin = File(root, "fake-codex").apply { mkdirs() } + File(bin, "codex").apply { + writeText("#!/bin/sh\necho \"codex-up:\$*\"\nexec sleep 60\n") + setExecutable(true) + } + status.writeText( + """{"v":1,"updatedAt":1,"nodes":{"term-a-1":{"state":"done","agentId":"codex","sessionId":"t-9","hibernated":true,"updatedAt":5}}, + "inbox":{"events":[],"nodes":{}}}""" + ) + fun paneOwner(): String = tmux("display-message", "-p", "-t", "=nt-term-a-1:", "#{pane_current_command}").second.trim() + try { + connect().use { conn -> + val snap = conn.listProjects() + assertEquals(true, snap.statusOf("term-a-1")?.hibernated) + val first = conn.attach("term-a-1", 100, 30, Sink()) + val wake = assertNotNull( + ResumeOffer.afterAttach(first.fresh, conn.kind, snap, "term-a-1", paneCommand = conn.paneCommand("term-a-1")) + ) + assertEquals("codex resume t-9", wake.command) + Thread.sleep(400) + first.write("PATH='${bin.path}':\$PATH; export PATH\r") + first.write(wake.keys) + val end = System.currentTimeMillis() + 8_000 + while (paneOwner() != "sleep" && System.currentTimeMillis() < end) Thread.sleep(100) + assertEquals("sleep", paneOwner(), "the stand-in codex holds the pane") + first.detach() + + // Nothing cleared the flag: the listing still says Sleeping. The next open reads the pane. + val again = conn.listProjects() + assertEquals(true, again.statusOf("term-a-1")?.hibernated, "the flag outlived the sleep") + val second = conn.attach("term-a-1", 100, 30, Sink()) + assertTrue(ResumeOffer.wantsPane(second.fresh, conn.kind, again, "term-a-1")) + val pane = conn.paneCommand("term-a-1") + assertEquals("sleep", pane) + assertNull(ResumeOffer.afterAttach(second.fresh, conn.kind, again, "term-a-1", paneCommand = pane), "no wake over a running CLI") + // An offer still on screen from before is withdrawn at the tap by the same read. + assertFalse(wake.stillOffered(again, "term-a-1", pane)) + second.detach() + } + } finally { + status.writeText(before) + project.writeText(projectBefore) + // Hand the shared session back to its shell for the other tests. + tmux("send-keys", "-t", "=nt-term-a-1:", "C-c") + val end = System.currentTimeMillis() + 5_000 + while (!Pane.isShell(paneOwner()) && System.currentTimeMillis() < end) Thread.sleep(100) + } + } + + @Test + fun `a pane that cannot be read is unknown, never a shell`() = runBlocking { + connect().use { conn -> + // No such session, a node of the desktop's SSH projects (its pane is on another host), an id + // no tmux target can be built from, and shell text (it becomes a session name that does not + // exist): all null, none of them a shell. + assertNull(conn.paneCommand("term-z-9")) + conn.rememberRemoteNodes(conn.listProjects()) + assertNull(conn.paneCommand("term-b-2")) + assertNull(conn.paneCommand("")) + assertNull(conn.paneCommand("x; rm -rf ~")) + } + } + + @Test + fun `the attach script itself refuses to create a missing session, on either socket`() { + // Belt and braces for the race where the session ends between the check and the attach. On + // nodeterm-rmt too (A27): a session a driving desktop makes there gets its remote tmux.conf + // and hook env, which an attach from the phone would not. A live server on each socket, so + // "missing" is a missing SESSION, not a socket nobody listens on. + try { + assertEquals(0, tmuxOn("nodeterm-rmt", "-f", "/dev/null", "new-session", "-d", "-s", "nt-keep-rmt", *paneShell).first) + for (socket in listOf("node-terminal", "nodeterm-rmt")) { + val (code, _) = run { + val pb = ProcessBuilder(ptyScript.argv(SshScripts.attach("term-y-8", socket))).directory(home) + pb.environment().clear() + pb.environment().putAll(childEnv()) + val p = pb.start() + p.outputStream.close() + p.waitFor(10, TimeUnit.SECONDS) + p.exitValue() to p.inputStream.bufferedReader().readText() + } + assertEquals(SshScripts.NO_SESSION_EXIT, code, socket) + assertEquals(1, tmuxOn(socket, "has-session", "-t", "=nt-term-y-8").first, socket) + } + } finally { + stopRmtServer() + } + } + + @Test + fun `nodes of the desktop's SSH projects are never reached on the desktop's own tmux`() = runBlocking { + // A09: term-b-2 belongs to the ssh project "Server" (me@box). Its session, approvals and acks + // live on that host; over direct SSH the phone refuses instead of acting on the wrong machine. + connect().use { conn -> + conn.listProjects() + val e = assertFailsWith { conn.attach("term-b-2", 80, 24, Sink()) } + assertTrue(e.message!!.contains("me@box"), e.message) + assertTrue(e.withoutRelay.contains("me@box"), e.withoutRelay) + assertNoRelayPromised(e) + assertFailsWith { conn.sendKeys("term-b-2", "1") } + val ev = InboxEvent("e2", 1, "term-b-2", "claude", null, InboxKind.APPROVAL, "Approve", null, false, false, emptyList(), false, "term-b-2-1-1") + assertFailsWith { conn.answerApproval(ev, allow = true) } + conn.ackRead("term-b-2", "e2") + assertFalse(File(home, ".nodeterm/acks/term-b-2.seen").exists(), "no ack written on the wrong machine") + assertEquals(1, tmux("has-session", "-t", "=nt-term-b-2").first, "no phantom session") + } + } + + @Test + fun `without a live SSH actions advertisement app verbs route to relay with an honest refusal`() = runBlocking { + // A26: An older host with no file service routes Board, new session and node actions to relay. + // Auto keeps its working SSH terminal leg; a caller + // that reaches the SSH transport anyway is not told to turn on remote access (it may be on). + connect().use { conn -> + for (cap in listOf(Capability.BOARD_WRITES, Capability.REGISTER_NODE, Capability.NODE_ACTIONS)) { + assertEquals(LegRouting.Leg.Relay, LegRouting.route(cap, conn.kind, conn.capabilities, LegRouting.RelayLeg.AVAILABLE), "$cap") + } + // What SSH does itself stays on SSH. + assertEquals( + LegRouting.Leg.Primary, + LegRouting.route(Capability.ANSWER_APPROVALS, conn.kind, conn.capabilities, LegRouting.RelayLeg.AVAILABLE) + ) + val refusals = listOf Unit>( + { conn.registerNode("p1", NewNode("term-n-1", "x", null, null)) }, + { conn.ensureBoard("p1") }, + { conn.setCardColumn("p1", "term-a-1", null) }, + { conn.editCardLabels("p1", "term-a-1", CardLabelEdit()) }, + { conn.wake("term-a-1") }, + { conn.rename("term-a-1", "x") } + ) + for (call in refusals) { + val e = assertFailsWith { call() } + assertFalse(e.message!!.contains("turn on remote access"), e.message) + assertTrue(e.message!!.contains("through the relay"), e.message) + } + } + } + + /** + * The review of A26: a relay token the phone holds outlives the computer's remote-access toggle. + * Each listing says whether the computer advertises its relay right now (`~/.nodeterm/relay.json`, + * written while the desktop's phone host is registered and removed when it stops), and with it + * off the app's verbs are unavailable with that reason instead of a tap that waits out the relay. + */ + @Test + fun `each listing says whether the computer advertises its relay, and off takes the stored leg away`() = runBlocking { + val ad = File(dotNodeterm, "relay.json") + try { + relayAdvertisementRoundTrip(ad) + } finally { + ad.delete() + } + } + + private suspend fun relayAdvertisementRoundTrip(ad: File) { + connect().use { conn -> + assertNull(conn.relayAdvertised, "unknown before the first listing") + conn.listProjects() + assertEquals(false, conn.relayAdvertised, "no relay.json: remote access is off") + val off = LegRouting.relayLeg(relayConfigured = true, sshOnlyRoute = false, relayAdvertised = conn.relayAdvertised) + assertEquals(LegRouting.RelayLeg.REMOTE_ACCESS_OFF, off) + for (cap in listOf(Capability.BOARD_WRITES, Capability.REGISTER_NODE, Capability.NODE_ACTIONS)) { + val leg = assertIs(LegRouting.route(cap, conn.kind, conn.capabilities, off), "$cap") + assertTrue(leg.reason.contains("remote access is off on the computer"), leg.reason) + } + + // Remote access turned on while the phone watches: the same connection's next listing sees it. + dotNodeterm.mkdirs() + ad.writeText("""{"v":1,"hostId":"h","hostPublicKeyB64":"k","relayEndpoint":"wss://relay.nodeterm.dev","hostDeviceId":"d"}""" + "\n") + conn.listProjects() + assertEquals(true, conn.relayAdvertised) + assertEquals(LegRouting.RelayLeg.AVAILABLE, LegRouting.relayLeg(true, false, relayAdvertised = conn.relayAdvertised)) + // A phone without a token adopts it now, on the poll, without a new connection. + assertTrue(LegRouting.adoptAfterListing(LegRouting.RelayLeg.NOT_PICKED_UP, false, conn.relayAdvertised, userAsked = false)) + + // An empty file is no advertisement; a removed one is off again. + ad.writeText("") + conn.listProjects() + assertEquals(false, conn.relayAdvertised) + ad.delete() + conn.listProjects() + assertEquals(false, conn.relayAdvertised) + } + } + + @Test + fun `send keys types literally, even text that starts with a dash`() = runBlocking { + connect().use { conn -> + conn.sendKeys("term-a-1", "-R; echo sk_\$((2+3))") + conn.sendKeys("term-a-1", "\r") + val end = System.currentTimeMillis() + 5_000 + var pane = "" + while (System.currentTimeMillis() < end) { + pane = tmux("capture-pane", "-p", "-t", "=nt-term-a-1:").second + if (pane.contains("sk_5")) break + Thread.sleep(100) + } + assertTrue(pane.contains("sk_5"), pane) + } + } + + @Test + fun `send keys refuses to confirm success when the server gives no exit status`() = runBlocking { + connect().use { conn -> + conn.listProjects() + try { + onCommand = { command, cmd -> + if (command.contains("send-keys")) cmd.omitExitStatus = true + } + val e = assertFailsWith { conn.sendKeys("term-a-1", "\u001b") } + assertTrue(e.message!!.contains("without a status"), e.message) + } finally { + onCommand = null + } + } + } + + @Test + fun `a held approval is answered through its answer file, exactly once`() = runBlocking { + val pending = File(home, ".nodeterm/pending").apply { mkdirs() } + val id = "term-a-1-1700000000000-99" + File(pending, "$id.json").writeText("{}") + val event = InboxEvent("e1", 1, "term-a-1", "claude", null, InboxKind.APPROVAL, "Approve", null, false, false, emptyList(), false, id) + connect().use { conn -> + assertEquals(ApprovalOutcome.SENT, conn.answerApproval(event, allow = false)) + assertEquals("deny", File(pending, "$id.answer").readText()) + File(pending, "$id.json").delete() + assertEquals(ApprovalOutcome.GONE, conn.answerApproval(event, allow = true)) + conn.ackRead("term-a-1", "e1") + assertEquals("e1", File(home, ".nodeterm/acks/term-a-1.seen").readText()) + } + } + + @Test + fun `structured hook replies use exact original rules and complete multi select input over real SSH stdin`() = runBlocking { + val pending = File(home, ".nodeterm/pending").apply { mkdirs() } + val id = "term-a-1-1700000000000-100" + val held = File(pending, "$id.json") + val event = InboxEvent("e2", 1, "term-a-1", "claude", null, InboxKind.APPROVAL, "Approve", null, false, false, emptyList(), false, id) + val json = kotlinx.serialization.json.Json + val permission = """{"hook_event_name":"PermissionRequest","tool_name":"Bash","permission_suggestions":[{"type":"addRules","behavior":"allow","destination":"session","rules":[{"toolName":"Bash","ruleContent":"npm test"}]}]}""" + held.writeText(permission) + connect().use { conn -> + assertEquals(ApprovalOutcome.SENT, conn.rememberApproval(event, 0)) + val remembered = File(pending, "$id.answer").readText() + assertTrue(remembered.startsWith(dev.nodeterm.protocol.model.HookReplies.MARKER + "\n")) + assertEquals(json.parseToJsonElement(permission).jsonObject["permission_suggestions"], + json.parseToJsonElement(remembered.substringAfter('\n')).jsonObject["hookSpecificOutput"]!!.jsonObject["decision"]!!.jsonObject["updatedPermissions"]) + File(pending, "$id.answer").delete() + val large = "x".repeat(70_000) + val request = """{"hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"extra":"$large","questions":[{"question":"Which?","header":"Pick","multiSelect":true,"options":[{"label":"α","description":""},{"label":"β","description":""}]}]}}""" + held.writeText(request) + val question = event.copy(kind = InboxKind.QUESTION, pendingId = null, questionPendingId = id) + assertEquals(ApprovalOutcome.SENT, conn.answerQuestions(question, listOf(listOf(0, 1)))) + val input = json.parseToJsonElement(File(pending, "$id.answer").readText().substringAfter('\n')).jsonObject["hookSpecificOutput"]!!.jsonObject["updatedInput"]!!.jsonObject + assertEquals(large, input["extra"]!!.jsonPrimitive.content) + assertEquals("α, β", input["answers"]!!.jsonObject["Which?"]!!.jsonPrimitive.content) + held.delete() + assertEquals(ApprovalOutcome.GONE, conn.answerQuestions(question, listOf(listOf(0)))) + } + pending.deleteRecursively() + } + + @Test fun `a structured hook write with lost exit acknowledgement stays uncertain without replay`() = runBlocking { + val pending = File(home, ".nodeterm/pending").apply { mkdirs() } + val id = "term-a-1-1700000000000-102" + val permission = """{"hook_event_name":"PermissionRequest","tool_name":"Bash","permission_suggestions":[{"type":"addRules","behavior":"allow","destination":"session","rules":[{"toolName":"Bash","ruleContent":"npm test"}]}]}""" + val question = """{"hook_event_name":"PreToolUse","tool_name":"AskUserQuestion","tool_input":{"questions":[{"question":"Which?","header":"Pick","multiSelect":true,"options":[{"label":"α","description":""},{"label":"β","description":""}]}]}}""" + val event = InboxEvent("lost-structured", 1, "term-a-1", "claude", null, InboxKind.APPROVAL, "Approve", null, false, false, emptyList(), false, id) + try { + for (approval in listOf(true, false)) { + File(pending, "$id.answer").delete() + File(pending, "$id.json").writeText(if (approval) permission else question) + connect().use { conn -> + conn.listProjects() + var writes = 0 + onCommand = { command, channel -> if (command.contains("cat >")) { writes++; channel.omitExitStatus = true } } + assertFailsWith { + if (approval) conn.rememberApproval(event, 0) + else conn.answerQuestions(event.copy(kind = InboxKind.QUESTION, pendingId = null, questionPendingId = id), listOf(listOf(0, 1))) + } + assertEquals(1, writes, "no automatic replay of an unanswered answer") + val answer = File(pending, "$id.answer").readText() + assertTrue(answer.startsWith(dev.nodeterm.protocol.model.HookReplies.MARKER + "\n")) + val output = kotlinx.serialization.json.Json.parseToJsonElement(answer.substringAfter('\n')).jsonObject.getValue("hookSpecificOutput").jsonObject + if (approval) assertNotNull(output["decision"]) + else assertEquals("α, β", output.getValue("updatedInput").jsonObject.getValue("answers").jsonObject.getValue("Which?").jsonPrimitive.content) + onCommand = null + } + } + } finally { onCommand = null; pending.deleteRecursively() } + } + + @Test + fun `structured reply refuses a changed held request and an unconfirmed command status`() = runBlocking { + val pending = File(home, ".nodeterm/pending").apply { mkdirs() } + val id = "term-a-1-1700000000000-101" + val held = File(pending, "$id.json") + val request = """{"hook_event_name":"PermissionRequest","tool_name":"Bash","permission_suggestions":[{"type":"addRules","behavior":"allow","destination":"session","rules":[{"toolName":"Bash","ruleContent":"npm test"}]}]}""" + val event = InboxEvent("e3", 1, "term-a-1", "claude", null, InboxKind.APPROVAL, "Approve", null, false, false, emptyList(), false, id) + connect().use { conn -> + conn.listProjects() + try { + held.writeText(request) + onCommand = { command, _ -> if (command.contains("cat >")) held.writeText(request.replace("npm test", "npm changed")) } + assertFailsWith { conn.rememberApproval(event, 0) } + assertFalse(File(pending, "$id.answer").exists()) + held.writeText(request) + onCommand = { command, cmd -> if (command.contains("cat >")) cmd.omitExitStatus = true } + assertFailsWith { conn.rememberApproval(event, 0) } + } finally { onCommand = null; pending.deleteRecursively() } + } + } + + // ---- Audit A27: a computer a desktop drives over SSH, and the Server Edition ---------------------- + + private val remoteRepo get() = File(root, "remote-repo") + private val dotNodeterm get() = File(home, ".nodeterm") + + /** + * What a desktop that drives this computer over SSH leaves here, written in the shapes its own + * code writes them (hand-copied, like the rest of this class's layout — docs/android.md "Interop + * tests"): an SSH project's canvas in `/.nodeterm/project.json` with the desktop's + * project id as `id` (`projectToFile(p, …, p.id)`), the per-project status slices + * `~/.nodeterm/agent-status-.json` (`filterMirrorForNodes` + the host's settings block, + * remote-status-push.ts) — one fresh, one a connected desktop's with no project file, one stale — + * and sessions on the `nodeterm-rmt` socket started in the node's folder (`new-session -c`). + */ + private fun layOutDrivenHost(now: Long) { + File(remoteRepo, ".nodeterm").mkdirs() + File(remoteRepo, "sub").mkdirs() + File(remoteRepo, ".nodeterm/project.json").writeText( + """{"version":1,"rev":4,"savedAt":"x","id":"project-drv","name":"Remote repo","color":"#30d158", + "viewport":{"x":0,"y":0,"zoom":1}, + "nodes":[{"id":"term-r-1","kind":"terminal","title":"Claude here","color":"#d97757","group":null, + "agentId":"claude","cwd":"${File(remoteRepo, "sub").path}","position":{"x":0,"y":0},"size":{"width":1,"height":1}}, + {"id":"term-r-2","kind":"terminal","title":"Shell here","color":"#000","group":null, + "position":{"x":0,"y":0},"size":{"width":1,"height":1}}, + {"id":"term-r-3","kind":"terminal","title":"Not running","color":"#000","group":null,"agentId":"claude", + "position":{"x":0,"y":0},"size":{"width":1,"height":1}}], + "kanban":{"columns":[{"id":"c9","title":"Doing","color":"#0a84ff"}],"assignments":[{"nodeId":"term-r-1","columnId":"c9"}]}}""" + ) + dotNodeterm.mkdirs() + File(dotNodeterm, "agent-status-project-drv.json").writeText( + """{"v":1,"updatedAt":$now,"nodes":{"term-r-1":{"state":"working","agentId":"claude","sessionId":"sid-r1","updatedAt":$now}}, + "inbox":{"events":[{"id":"ev-r1","ts":$now,"nodeId":"term-r-1","agentId":"claude","kind":"approval", + "title":"Run the tests?","pendingId":"term-r-1-1700000000000-7"}],"nodes":{}}, + "settings":{"claudePermissionMode":"plan","autoSupported":true,"claudeAccounts":[]}}""" + ) + File(dotNodeterm, "agent-status-project-live2.json").writeText( + """{"v":1,"updatedAt":$now,"nodes":{"term-s-7":{"state":"done","agentId":"codex","updatedAt":$now}}}""" + ) + val old = now - 5 * 60_000 + File(dotNodeterm, "agent-status-project-gone.json").writeText( + """{"v":1,"updatedAt":$old,"nodes":{"term-g-1":{"state":"blocked","agentId":"claude","updatedAt":$old}}, + "inbox":{"events":[{"id":"ev-g1","ts":$old,"nodeId":"term-g-1","kind":"approval","title":"Stale"}],"nodes":{}}}""" + ) + val elsewhere = File(root, "elsewhere").apply { mkdirs() } + for ((name, dir) in listOf("nt-term-r-1" to File(remoteRepo, "sub"), "nt-term-r-2" to remoteRepo, "nt-term-g-1" to elsewhere)) { + val (code, out) = tmuxOn("nodeterm-rmt", "-f", "/dev/null", "new-session", "-d", "-s", name, "-c", dir.path, *paneShell) + assertEquals(0, code, out) + } + } + + private fun clearDrivenHost() { + stopRmtServer() + dotNodeterm.listFiles()?.filter { it.name.startsWith("agent-status-") }?.forEach { it.delete() } + remoteRepo.deleteRecursively() + } + + /** + * Stop this class's `nodeterm-rmt` server and wait until it is gone: a server exits AFTER + * `kill-server` (or the end of its last session) returns, and a client that connects meanwhile + * gets "server exited unexpectedly" instead of starting a new one. + */ + private fun stopRmtServer() { + tmuxOn("nodeterm-rmt", "kill-server") + val end = System.currentTimeMillis() + 5_000 + while (System.currentTimeMillis() < end) { + val (code, out) = tmuxOn("nodeterm-rmt", "list-sessions") + if (code != 0 && !out.contains("server exited unexpectedly")) return + Thread.sleep(50) + } + } + + /** Run [block] with the desktop app's userData moved away: a computer no nodeterm runs on. */ + private fun withoutOwnData(block: () -> T): T { + val ud = File(home, ".config/node-terminal") + val aside = File(root, "ud-aside") + check(ud.renameTo(aside)) + try { + return block() + } finally { + check(aside.renameTo(ud)) + } + } + + private fun waitForPane(socket: String, session: String, needle: String): String { + val end = System.currentTimeMillis() + 8_000 + var pane = "" + while (System.currentTimeMillis() < end) { + pane = tmuxOn(socket, "capture-pane", "-p", "-J", "-t", "=$session:").second + if (pane.contains(needle)) break + Thread.sleep(100) + } + return pane + } + + @Test + fun `a computer another desktop drives over SSH lists its projects, sessions and status from what that desktop left there`() = runBlocking { + val now = System.currentTimeMillis() + try { + layOutDrivenHost(now) + withoutOwnData { + runBlocking { + connect().use { conn -> + val snap = conn.listProjects() + assertEquals( + listOf("Remote repo", "project-live2", HostBrowse.OTHER_SESSIONS_NAME), + snap.projects.map { it.name }, + "the project file, a connected desktop's slice with no file, and the sessions nobody names" + ) + assertTrue(snap.projects.all { it.drivenRemotely && it.sshTarget == null }) + val drv = snap.projects[0] + assertEquals("project-drv", drv.id, "the desktop's project id, which its slice is named by") + assertEquals(remoteRepo.path, drv.cwd) + assertEquals(listOf("term-r-1", "term-r-2", "term-r-3"), drv.nodes.map { it.id }) + assertEquals("c9", drv.board!!.columnOf("term-r-1")) + assertEquals(listOf("term-s-7"), snap.projects[1].nodes.map { it.id }) + assertEquals(listOf("term-g-1"), snap.projects[2].nodes.map { it.id }, "a stale slice names no project") + + assertTrue(snap.isLive("term-r-1") && snap.isLive("term-r-2") && snap.isLive("term-g-1")) + assertFalse(snap.isLive("term-r-3")) + assertEquals("nodeterm-rmt", snap.socketOf("term-r-1")) + assertEquals(AgentState.WORKING, snap.statusOf("term-r-1")!!.state) + assertEquals(AgentState.DONE, snap.statusOf("term-s-7")!!.state) + assertNull(snap.statusOf("term-g-1"), "a slice older than twice the heartbeat is no data") + assertEquals(listOf("ev-r1"), snap.status!!.inbox!!.events.map { it.id }) + assertEquals("plan", snap.status!!.settings!!.claudePermissionMode, "the slice carries the host's settings") + + // What needs nodeterm the app is that OTHER desktop's. + assertTrue(NewSessionChoice.offeredProjects(snap).isEmpty()) + assertIs( + LegRouting.forProject(Capability.BOARD_WRITES, drv, LegRouting.Leg.Relay) + ) + + // The session runs HERE, on nodeterm-rmt: attach, type, read its pane. + val sink = Sink() + val stream = conn.attach("term-r-1", 100, 30, sink) + assertFalse(stream.fresh) + Thread.sleep(400) + stream.write("echo rmt_\$((6*7))\r") + sink.waitFor("rmt_42") + assertTrue(waitForPane("nodeterm-rmt", "nt-term-r-1", "rmt_42").contains("rmt_42"), "typed into the rmt session") + stream.detach() + Thread.sleep(300) + assertEquals(0, tmuxOn("nodeterm-rmt", "has-session", "-t", "=nt-term-r-1").first, "detaching never ends it") + + conn.sendKeys("term-r-2", "echo sk_rmt_\$((3*3))") + conn.sendKeys("term-r-2", "\r") + assertTrue(waitForPane("nodeterm-rmt", "nt-term-r-2", "sk_rmt_9").contains("sk_rmt_9")) + assertTrue(Pane.isShell(conn.paneCommand("term-r-2")), "the pane read reaches the rmt socket") + + // Not running: only its own desktop starts it, and the phone's relay is not that + // desktop's, so there is nothing to offer — and nothing is created on either socket. + val e = assertFailsWith { conn.attach("term-r-3", 80, 24, Sink()) } + assertFalse(e is NeedsRelayException) + assertEquals(SshHostConnection.DRIVEN_NOT_RUNNING, e.message) + // The same on a connection that has not listed yet (a redial, a terminal restored + // after the process died): the review of A27a. + connect().use { fresh -> + val e2 = assertFailsWith { fresh.attach("term-r-3", 80, 24, Sink()) } + assertFalse(e2 is NeedsRelayException, "no relay offered on a connection that never listed") + assertEquals(SshHostConnection.DRIVEN_NOT_RUNNING, e2.message) + } + Thread.sleep(300) + assertEquals(1, tmuxOn("nodeterm-rmt", "has-session", "-t", "=nt-term-r-3").first) + assertEquals(1, tmux("has-session", "-t", "=nt-term-r-3").first) + + // The held approval and the read-ack are files on THIS computer, where the driving + // desktop's SSH answer path and ack sweep look for them. + val pending = File(dotNodeterm, "pending").apply { mkdirs() } + val ev = snap.status!!.inbox!!.events.single() + File(pending, "${ev.pendingId}.json").writeText("{}") + assertEquals(ApprovalOutcome.SENT, conn.answerApproval(ev, allow = true)) + assertEquals("allow", File(pending, "${ev.pendingId}.answer").readText()) + conn.ackRead("term-r-1", "ev-r1") + assertEquals("ev-r1", File(dotNodeterm, "acks/term-r-1.seen").readText()) + + // Ending it stops the rmt session and nothing on the host's own socket. + conn.killSession("term-r-2") + assertEquals(1, tmuxOn("nodeterm-rmt", "has-session", "-t", "=nt-term-r-2").first) + assertEquals(0, tmux("has-session", "-t", "=nt-term-a-1").first) + } + } + } + } finally { + clearDrivenHost() + } + } + + @Test + fun `with both sockets in use the host's own projects come first, and the paired desktop's SSH project stays its own (A09)`() = runBlocking { + val now = System.currentTimeMillis() + // The paired desktop drives THIS computer as its SSH project "Server" (p2): that project's + // file and session are here too, and must not turn into a second, directly reachable project. + val selfSsh = File(root, "self-ssh/.nodeterm") + try { + layOutDrivenHost(now) + selfSsh.mkdirs() + File(selfSsh, "project.json").writeText( + """{"version":1,"rev":1,"savedAt":"x","id":"p2","name":"Server again","color":"#000","viewport":{"x":0,"y":0,"zoom":1}, + "nodes":[{"id":"term-b-2","kind":"terminal","title":"Remote","color":"#000","group":null,"position":{"x":0,"y":0},"size":{"width":1,"height":1}}]}""" + ) + assertEquals(0, tmuxOn("nodeterm-rmt", "new-session", "-d", "-s", "nt-term-b-2", "-c", selfSsh.parentFile.path, *paneShell).first) + // A name on BOTH sockets is the host's own session (first wins, as the desktop's sweep does). + assertEquals(0, tmuxOn("nodeterm-rmt", "new-session", "-d", "-s", "nt-term-a-1", "-c", root.path, *paneShell).first) + connect().use { conn -> + val snap = conn.listProjects() + assertEquals( + listOf("Repo", "Server", "Scratch", "Parked", "Remote repo", "project-live2", HostBrowse.OTHER_SESSIONS_NAME), + snap.projects.map { it.name } + ) + assertEquals(listOf("term-g-1"), snap.projects.last().nodes.map { it.id }, "neither term-a-1 nor term-b-2 is an orphan") + assertEquals("node-terminal", snap.socketOf("term-a-1")) + assertEquals("nodeterm-rmt", snap.socketOf("term-r-1")) + assertEquals(AgentState.WORKING, snap.statusOf("term-a-1")!!.state, "the host's own mirror") + assertEquals(AgentState.WORKING, snap.statusOf("term-r-1")!!.state, "the slice") + + // The desktop's own SSH project: still reached through the desktop. + val e = assertFailsWith { conn.attach("term-b-2", 80, 24, Sink()) } + assertTrue(e.message!!.contains("me@box"), e.message) + + // term-a-1 is attached on node-terminal, never on the rmt session of the same name. + val sink = Sink() + val stream = conn.attach("term-a-1", 100, 30, sink) + Thread.sleep(400) + stream.write("echo own_\$((4*4))\r") + sink.waitFor("own_16") + assertTrue(waitForPane("node-terminal", "nt-term-a-1", "own_16").contains("own_16")) + assertFalse(tmuxOn("nodeterm-rmt", "capture-pane", "-p", "-t", "=nt-term-a-1:").second.contains("own_16")) + stream.detach() + } + } finally { + clearDrivenHost() + selfSsh.parentFile.deleteRecursively() + } + } + + @Test + fun `a Server Edition's data dir is found, and its install metadata read`() = runBlocking { + val data = File(home, ".nodeterm-server").apply { mkdirs() } + File(data, "workspace.json").writeText( + """{"version":3,"activeProjectId":"p1","entries":[{"id":"p1","name":"Served repo","color":"#0a84ff","cwd":"${repo.path}"}]}""" + ) + File(data, "agent-status.json").writeText( + """{"v":1,"updatedAt":1,"nodes":{"term-a-1":{"state":"done","agentId":"claude","updatedAt":5}}, + "server":{"version":"0.2.17","commit":"1e56f83","installedAt":"2026-09-01T10:00:00.000Z"}}""" + ) + try { + withoutOwnData { + runBlocking { + connect().use { conn -> + val snap = conn.listProjects() + assertEquals(listOf("Served repo"), snap.projects.map { it.name }) + assertEquals(listOf("term-a-1"), snap.projects.single().nodes.map { it.id }) + assertFalse(snap.projects.single().drivenRemotely) + assertEquals(AgentState.DONE, snap.statusOf("term-a-1")!!.state) + assertEquals("nodeterm server 0.2.17 · 1e56f83 · installed 2026-09-01", snap.status!!.server!!.describe()) + } + } + } + } finally { + data.deleteRecursively() + } + } + + @Test + fun `nothing of nodeterm's found is an error that says where it looked, not an empty computer`() = runBlocking { + withoutOwnData { + runBlocking { + connect().use { conn -> + val e = assertFailsWith { conn.listProjects() } + assertEquals(SshHostConnection.NO_USER_DATA, e.message) + assertTrue(e.message!!.contains("~/.nodeterm-server") && e.message!!.contains("--data-dir"), e.message) + // The relay is offered only to a phone that holds a relay leg (the review of A27b): + // a computer added by its SSH address never has one. + for (leg in LegRouting.RelayLeg.entries) { + val said = e.said(leg) + assertTrue(said.startsWith(SshHostConnection.NOT_FOUND), "$leg: $said") + val offered = leg == LegRouting.RelayLeg.AVAILABLE || leg == LegRouting.RelayLeg.ROUTE_SSH_ONLY + assertEquals(offered, said.contains("relay"), "$leg: $said") + } + assertTrue(e.said(LegRouting.RelayLeg.ADDED_OVER_SSH).contains("added as the user")) + } + } + } + } + + @Test + fun `a computer a desktop drove once, where only its stale slices remain, is still nothing found`() = runBlocking { + // The review of A27a: the desktop never deletes ~/.nodeterm/agent-status-.json, so + // once a desktop has driven a computer its slices stay. Old ones are no data, and must not + // turn "not found, here is where the phone looked" into an empty computer. + val old = System.currentTimeMillis() - 10 * 60_000 + dotNodeterm.mkdirs() + File(dotNodeterm, "agent-status-project-gone.json").writeText( + """{"v":1,"updatedAt":$old,"nodes":{"term-g-1":{"state":"blocked","agentId":"claude","updatedAt":$old}}}""" + ) + try { + withoutOwnData { + runBlocking { + connect().use { conn -> + val e = assertFailsWith { conn.listProjects() } + assertEquals(SshHostConnection.NO_USER_DATA, e.message) + } + } + } + } finally { + clearDrivenHost() + } + } + + @Test + fun `the host key is pinned on first use and a changed key is refused`() { + val pin = MemoryPin() + connect(pin).close() + assertTrue(pin.value!!.startsWith("SHA256:")) + connect(pin).close() + assertFailsWith { connect(MemoryPin("SHA256:not-this-host")) } + } + + @Test + fun `a server that refuses our key never becomes the pin (A49)`() { + // What answers at the paired address after the DHCP lease moved, or on another network that + // uses the same private range: an sshd that completes the key exchange and then refuses the + // phone's key. Pinning its host key during the exchange made the real computer "changed" on + // the next connect. + val pin = MemoryPin() + assertFailsWith { + SshHostConnection.connect("127.0.0.1", port, "dev", SshIdentity.generate(), pin).close() + } + assertNull(pin.value, "a key exchange whose authentication failed must not pin") + assertFailsWith { + SshHostConnection.connect("127.0.0.1", port, "someone-else", identity, pin).close() + } + assertNull(pin.value, "a refused user must not pin either") + // The first connect that AUTHENTICATES pins exactly the server's host key. + connect(pin).close() + val serverKey = server.keyPairProvider.loadKeys(null).first().public + assertEquals(SshHostConnection.fingerprint(serverKey), pin.value) + } + + @Test + fun `a first connect must present a key the pairing named, refused before our key is offered (A49-anchor)`() { + val serverFp = SshHostConnection.fingerprint(server.keyPairProvider.loadKeys(null).first().public) + val stranger = "SHA256:" + "A".repeat(43) + // The computer named other keys at pairing: whatever answers here is not one of them. + val pin = MemoryPin(paired = listOf(stranger)) + val before = authAttempts.get() + val refused = assertFailsWith { connect(pin).close() } + assertNull(pin.value, "a key the pairing did not name must never become the pin") + assertEquals(before, authAttempts.get(), "the phone's key must not be offered to a server the pairing did not name") + assertEquals(serverFp, refused.actual) + assertEquals(listOf(stranger), refused.paired) + assertTrue(refused.message!!.contains("the computer reported to this phone (when it was paired, or since through the relay)"), refused.message) + // A HostKeyChangedException, so SshFallback refuses SSH and in Auto still tries the relay. + assertIs(refused) + + // The computer named this server's key among others: it connects and pins exactly that key. + val anchored = MemoryPin(paired = listOf(stranger, serverFp)) + connect(anchored).close() + assertEquals(serverFp, anchored.value) + // Later connects verify the pin, as they always did. + connect(anchored).close() + } + + @Test + fun `a key the computer does not know is refused`() { + assertFailsWith { + SshHostConnection.connect("127.0.0.1", port, "dev", SshIdentity.generate(), MemoryPin()).close() + } + // …and the same session was never at risk. + assertEquals(0, tmux("has-session", "-t", "=nt-term-a-1").first) + TimeUnit.MILLISECONDS.sleep(1) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalActionsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalActionsTest.kt new file mode 100644 index 0000000000..3bb774252e --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalActionsTest.kt @@ -0,0 +1,459 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.TerminalActions +import dev.nodeterm.protocol.host.TerminalStream +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TerminalActionsTest { + private class Stream : TerminalStream { + override val fresh = false + val log = mutableListOf() + var onScroll: suspend (Boolean, Int) -> Unit = { _, _ -> } + var onWrite: (String) -> Unit = {} + override fun write(text: String) { log += "input:$text"; onWrite(text) } + override fun resize(cols: Int, rows: Int) {} + override suspend fun scroll(up: Boolean, lines: Int) { + log += "scroll:$up:$lines" + onScroll(up, lines) + } + override suspend fun detach() {} + override suspend fun endSession() {} + } + + @Test + fun `adjacent direction coalesces without losing distance and every host call is at most twenty`() = runBlocking { + val stream = Stream() + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.scroll(true, 17)) + assertTrue(actions.scroll(true, 28)) + assertTrue(actions.scroll(false, 6)) + assertTrue(actions.scroll(true, 2)) + yield() + assertEquals(listOf("scroll:true:20", "scroll:true:20", "scroll:true:5", "scroll:false:6", "scroll:true:2"), stream.log) + } finally { actions.close() } + } + + @Test + fun `a suspended relay scroll serializes subsequent chunks and a direction reversal`() = runBlocking { + val stream = Stream() + val started = CompletableDeferred() + val release = CompletableDeferred() + stream.onScroll = { _, _ -> if (!started.isCompleted) { started.complete(Unit); release.await() } } + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.scroll(true, 35)) + withTimeout(2_000) { started.await() } + assertTrue(actions.scroll(false, 8)) + yield() + assertEquals(listOf("scroll:true:20"), stream.log, "No concurrent RPC or reversal may overtake the suspended call") + release.complete(Unit) + yield() + assertEquals(listOf("scroll:true:20", "scroll:true:15", "scroll:false:8"), stream.log) + } finally { actions.close() } + } + + @Test + fun `Esc cancels unsent chunks and directions but waits for the already sent RPC`() = runBlocking { + val stream = Stream() + val started = CompletableDeferred() + val release = CompletableDeferred() + stream.onScroll = { _, _ -> if (!started.isCompleted) { started.complete(Unit); release.await() } } + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.scroll(true, 80)) + withTimeout(2_000) { started.await() } + assertTrue(actions.scroll(false, 12)) + assertTrue(actions.write("\u001b")) + assertTrue(actions.write("command\r")) + yield() + assertEquals(listOf("scroll:true:20"), stream.log) + release.complete(Unit) + yield() + assertEquals(listOf("scroll:true:20", "input:\u001b", "input:command\r"), stream.log) + assertTrue(actions.scroll(false, 3), "A new gesture after input is still allowed") + yield() + assertEquals("scroll:false:3", stream.log.last()) + } finally { actions.close() } + } + + @Test + fun `input barrier preserves accepted input while cancelling scroll on both sides of it`() = runBlocking { + val stream = Stream() + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.write("first")) + assertTrue(actions.scroll(true, 20)) + assertTrue(actions.write("second")) + assertTrue(actions.scroll(false, 10)) + assertTrue(actions.write("third")) + yield() + assertEquals(listOf("input:first", "input:second", "input:third"), stream.log) + } finally { actions.close() } + } + + @Test + fun `automatic replies arriving during a scroll retain every chunk and direction in FIFO order`() = runBlocking { + val stream = Stream() + val started = CompletableDeferred() + val release = CompletableDeferred() + stream.onScroll = { _, _ -> if (!started.isCompleted) { started.complete(Unit); release.await() } } + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.scroll(true, 45)) + withTimeout(2_000) { started.await() } + assertTrue(actions.report("\u001b[1;1R")) + assertTrue(actions.scroll(false, 7)) + assertTrue(actions.report("\u001b[?1;2c")) + assertTrue(actions.scroll(true, 3)) + yield() + assertEquals(listOf("scroll:true:20"), stream.log) + release.complete(Unit) + yield() + assertEquals(listOf("scroll:true:20", "scroll:true:20", "scroll:true:5", "input:\u001b[1;1R", + "scroll:false:7", "input:\u001b[?1;2c", "scroll:true:3"), stream.log) + } finally { actions.close() } + } + + @Test + fun `Esc still cancels pending gesture distance while preserving automatic replies`() = runBlocking { + val stream = Stream() + val started = CompletableDeferred() + val release = CompletableDeferred() + stream.onScroll = { _, _ -> if (!started.isCompleted) { started.complete(Unit); release.await() } } + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.scroll(true, 80)) + withTimeout(2_000) { started.await() } + assertTrue(actions.report("reply-before")) + assertTrue(actions.scroll(false, 10)) + assertTrue(actions.write("\u001b")) + assertTrue(actions.report("reply-after")) + release.complete(Unit) + yield() + assertEquals(listOf("scroll:true:20", "input:reply-before", "input:\u001b", "input:reply-after"), stream.log) + } finally { actions.close() } + } + + @Test + fun `automatic replies share pending run and character bounds without cancelling accepted movement`() = runBlocking { + val stream = Stream() + val actions = TerminalActions(this, stream) { true } + try { + repeat(64) { assertTrue(actions.scroll(it % 2 == 0, 1)) } + assertFalse(actions.report("reply"), "Reports count queued scroll runs against capacity") + yield() + assertEquals(64, stream.log.size) + stream.log.clear() + assertTrue(actions.report("a".repeat(1024 * 1024))) + assertTrue(actions.scroll(true, 13)) + assertFalse(actions.report("overflow")) + assertFalse(actions.write("overflow"), "User input shares the report's character budget") + yield() + assertEquals(2, stream.log.size) + assertEquals("scroll:true:13", stream.log.last()) + repeat(64) { assertTrue(actions.report("$it")) } + assertFalse(actions.report("65th")) + } finally { actions.close() } + } + + @Test + fun `a failed automatic reply is not retried and does not cancel following movement`() = runBlocking { + val stream = Stream() + stream.onWrite = { if (it == "reply") throw IllegalStateException("reply refused") } + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.scroll(true, 9)) + assertTrue(actions.report("reply")) + assertTrue(actions.scroll(false, 6)) + yield() + assertEquals(listOf("scroll:true:9", "input:reply", "scroll:false:6"), stream.log) + assertTrue(actions.report("next")) + yield() + assertEquals("input:next", stream.log.last()) + } finally { actions.close() } + } + + @Test + fun `momentum cancellation preserves admitted input and reports while allowing new direction`() = runBlocking { + val stream = Stream() + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.write("typed")) + assertTrue(actions.scroll(true, 45)) + assertTrue(actions.report("reply-one")) + assertTrue(actions.scroll(false, 9)) + assertTrue(actions.report("reply-two")) + assertTrue(actions.cancelScroll()) + assertTrue(actions.cancelScroll(), "Stopping an already stopped gesture is harmless") + assertTrue(actions.scroll(false, 7)) + yield() + assertEquals(listOf("input:typed", "input:reply-one", "input:reply-two", "scroll:false:7"), stream.log) + } finally { actions.close() } + } + + @Test + fun `momentum cancellation waits for one in flight scroll and preserves report order`() = runBlocking { + val stream = Stream() + val started = CompletableDeferred() + val release = CompletableDeferred() + stream.onScroll = { _, _ -> if (!started.isCompleted) { started.complete(Unit); release.await() } } + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.scroll(true, 65)) + withTimeout(2_000) { started.await() } + assertTrue(actions.report("reply-before")) + assertTrue(actions.scroll(false, 13)) + assertTrue(actions.report("reply-after")) + assertTrue(actions.cancelScroll()) + assertTrue(actions.scroll(false, 6)) + assertTrue(actions.report("new-reply")) + yield() + assertEquals(listOf("scroll:true:20"), stream.log, "Stopping momentum does not cancel or overtake the awaited call") + release.complete(Unit) + yield() + assertEquals(listOf("scroll:true:20", "input:reply-before", "input:reply-after", "scroll:false:6", "input:new-reply"), stream.log) + } finally { actions.close() } + } + + @Test + fun `momentum cancellation keeps in flight and input budgets charged`() = runBlocking { + val stream = Stream() + val started = CompletableDeferred() + val release = CompletableDeferred() + stream.onScroll = { _, _ -> if (!started.isCompleted) { started.complete(Unit); release.await() } } + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.scroll(true, 320)) + withTimeout(2_000) { started.await() } + val reply = "r".repeat(1024 * 1024) + assertTrue(actions.report(reply)) + assertTrue(actions.cancelScroll()) + assertTrue(actions.scroll(false, 300), "Only the awaited twenty remain charged after cancellation") + assertFalse(actions.scroll(false, 1), "Stopping momentum cannot free the in-flight budget") + assertFalse(actions.report("overflow"), "Accepted reports retain their character budget") + assertFalse(actions.write("overflow")) + release.complete(Unit) + yield() + assertEquals("scroll:true:20", stream.log[0]) + assertEquals("input:$reply", stream.log[1]) + assertEquals(300, stream.log.drop(2).sumOf { it.substringAfterLast(':').toInt() }) + assertTrue(actions.scroll(true, 320), "Completed work releases its budget after cancellation too") + } finally { actions.close() } + } + + @Test + fun `momentum cancellation cannot revive a retired obsolete or cancelled viewer`() = runBlocking { + val stream = Stream() + var current = true + val actions = TerminalActions(this, stream) { current } + assertTrue(actions.scroll(true, 9)) + current = false + assertFalse(actions.cancelScroll()) + yield() + assertTrue(stream.log.isEmpty()) + current = true + assertFalse(actions.cancelScroll(), "An obsolete drain retires rather than being revived") + actions.close() + assertFalse(actions.cancelScroll()) + val owner = Job() + val cancelled = TerminalActions(CoroutineScope(coroutineContext + owner), stream) { true } + owner.cancel() + assertFalse(cancelled.cancelScroll()) + cancelled.close() + owner.join() + assertTrue(stream.log.isEmpty()) + } + + @Test + fun `close cancels suspended RPC and clears pending input and scroll permanently`() = runBlocking { + val stream = Stream() + val started = CompletableDeferred() + val cancelled = CompletableDeferred() + val never = CompletableDeferred() + stream.onScroll = { _, _ -> + started.complete(Unit) + try { never.await() } finally { cancelled.complete(Unit) } + } + val actions = TerminalActions(this, stream) { true } + assertTrue(actions.scroll(true, 50)) + withTimeout(2_000) { started.await() } + assertTrue(actions.write("\u001b")) + actions.close() + actions.close() + withTimeout(2_000) { cancelled.await() } + assertFalse(actions.scroll(false, 1)) + assertFalse(actions.write("late")) + assertFalse(actions.report("late reply")) + assertEquals(listOf("scroll:true:20"), stream.log) + } + + @Test + fun `an obsolete viewer never executes queued actions on either the old or new stream`() = runBlocking { + val old = Stream() + val replacement = Stream() + var current = true + val oldActions = TerminalActions(this, old) { current } + val newActions = TerminalActions(this, replacement) { true } + try { + assertTrue(oldActions.scroll(true, 40)) + current = false + assertFalse(oldActions.write("obsolete")) + assertFalse(oldActions.report("obsolete reply")) + assertTrue(newActions.write("new")) + yield() + assertEquals(emptyList(), old.log) + assertEquals(listOf("input:new"), replacement.log) + } finally { oldActions.close(); newActions.close() } + } + + @Test + fun `becoming obsolete during an RPC prevents its remaining distance and input from running`() = runBlocking { + val stream = Stream() + val started = CompletableDeferred() + val release = CompletableDeferred() + var current = true + stream.onScroll = { _, _ -> if (!started.isCompleted) { started.complete(Unit); release.await() } } + val actions = TerminalActions(this, stream) { current } + try { + assertTrue(actions.scroll(true, 60)) + withTimeout(2_000) { started.await() } + assertTrue(actions.write("late")) + current = false + release.complete(Unit) + yield() + assertEquals(listOf("scroll:true:20"), stream.log) + assertFalse(actions.scroll(true, 1)) + } finally { actions.close() } + } + + @Test + fun `a failed RPC is not retried but input and a later explicit gesture remain usable`() = runBlocking { + val stream = Stream() + val started = CompletableDeferred() + val release = CompletableDeferred() + stream.onScroll = { _, _ -> + if (!started.isCompleted) { + started.complete(Unit) + release.await() + throw IllegalStateException("RPC refused") + } + } + val actions = TerminalActions(this, stream) { true } + try { + assertTrue(actions.scroll(true, 60)) + withTimeout(2_000) { started.await() } + assertTrue(actions.write("\u001b")) + assertTrue(actions.scroll(false, 4)) + release.complete(Unit) + yield() + assertEquals(listOf("scroll:true:20", "input:\u001b", "scroll:false:4"), stream.log, + "Failure of the old gesture cannot cancel a new gesture admitted after explicit input") + assertTrue(actions.write("next")) + assertTrue(actions.scroll(false, 2)) + yield() + assertEquals(listOf("scroll:true:20", "input:\u001b", "scroll:false:4", "input:next", "scroll:false:2"), stream.log) + } finally { actions.close() } + } + + @Test + fun `RPC cancellation retires admission and does not deliver later input`() = runBlocking { + val stream = Stream() + stream.onScroll = { _, _ -> throw CancellationException("cancelled RPC") } + val actions = TerminalActions(this, stream) { true } + assertTrue(actions.scroll(true, 40)) + yield() + assertEquals(listOf("scroll:true:20"), stream.log) + assertFalse(actions.write("late")) + assertFalse(actions.report("late reply")) + assertFalse(actions.scroll(false, 1)) + actions.close() + } + + @Test + fun `scroll bounds include the suspended call and input clears the unsent budget`() = runBlocking { + val stream = Stream() + val started = CompletableDeferred() + val release = CompletableDeferred() + stream.onScroll = { _, _ -> if (!started.isCompleted) { started.complete(Unit); release.await() } } + val actions = TerminalActions(this, stream) { true } + try { + assertFalse(actions.scroll(true, 0)) + assertFalse(actions.scroll(true, -1)) + assertFalse(actions.scroll(true, Int.MAX_VALUE)) + assertTrue(actions.scroll(true, 320)) + withTimeout(2_000) { started.await() } + assertFalse(actions.scroll(true, 1), "The 20 notches in flight are still charged") + assertTrue(actions.write("\u001b")) + assertTrue(actions.scroll(false, 300), "Only the in-flight 20 remain after input") + assertFalse(actions.scroll(false, 1)) + release.complete(Unit) + yield() + assertEquals("input:\u001b", stream.log[1]) + assertEquals(300, stream.log.drop(2).sumOf { it.substringAfterLast(':').toInt() }) + assertTrue(actions.scroll(true, 320), "Completed actions release their budget") + } finally { actions.close() } + } + + @Test + fun `sixty four pending direction runs are bounded while same direction can still merge`() = runBlocking { + val stream = Stream() + val actions = TerminalActions(this, stream) { true } + try { + repeat(64) { assertTrue(actions.scroll(it % 2 == 0, 1)) } + assertFalse(actions.scroll(true, 1), "The 65th direction run is refused") + assertTrue(actions.scroll(false, 2), "A same-direction tail consumes no new run") + yield() + assertEquals(64, stream.log.size) + assertEquals("scroll:false:3", stream.log.last()) + assertTrue(actions.scroll(true, 1)) + } finally { actions.close() } + } + + @Test + fun `input has both a run and character bound and a refusal preserves previously accepted actions`() = runBlocking { + val stream = Stream() + val actions = TerminalActions(this, stream) { true } + try { + repeat(64) { assertTrue(actions.write("$it")) } + assertFalse(actions.scroll(true, 1)) + assertFalse(actions.write("65th")) + yield() + assertEquals((0 until 64).map { "input:$it" }, stream.log) + stream.log.clear() + val limit = "a".repeat(1024 * 1024) + assertTrue(actions.write(limit)) + assertTrue(actions.scroll(true, 17)) + assertFalse(actions.write("x"), "A refused input must not clear the accepted scroll") + assertFalse(actions.write(limit + "x")) + yield() + assertEquals(2, stream.log.size) + assertEquals(limit.length + "input:".length, stream.log[0].length) + assertEquals("scroll:true:17", stream.log[1]) + assertTrue(actions.write("next"), "Delivered input releases its character budget") + } finally { actions.close() } + } + + @Test + fun `a cancelled owner scope refuses new actions without leaving an idle drain`() = runBlocking { + val owner = Job() + val stream = Stream() + val actions = TerminalActions(CoroutineScope(coroutineContext + owner), stream) { true } + owner.cancel() + assertFalse(actions.write("late")) + assertFalse(actions.scroll(true, 1)) + actions.close() + owner.join() + assertTrue(stream.log.isEmpty()) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalActionsWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalActionsWiringTest.kt new file mode 100644 index 0000000000..e184d5a83d --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalActionsWiringTest.kt @@ -0,0 +1,105 @@ +package dev.nodeterm.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Native lifecycle/bridge callbacks need a device; pin their delegation to the tested queue. */ +class TerminalActionsWiringTest { + private fun controller() = AppSourcePins.ui("TerminalController.kt") + .replace(Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL), "") + .replace(Regex("""(?m)^\s*//.*$"""), "") + + @Test + fun `a queue belongs to the accepted stream and ticket and starts before JS scrolling resumes`() { + val source = controller() + val attach = AppSourcePins.blockAfter(source, "private fun attach()") + AppSourcePins.assertInOrder(attach, "retireActions()", "nt.suspendScroll()", "slot.begin()") + AppSourcePins.assertInOrder(attach, "if (!slot.accept(ticket, lease)) return@post", + "actor = TerminalActions(graph.scope, s, onScrollView", "actions = actor", "nt.resumeScroll()") + assertEquals(1, Regex("""\bTerminalActions\(""").findAll(source).count()) + } + + @Test + fun `every viewer retirement closes its queue and stop suspends the page before detaching`() { + val source = controller() + val retire = AppSourcePins.blockAfter(source, "private fun retireActions()") + AppSourcePins.assertInOrder(retire, "actions?.close()", "actions = null") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "override fun onExit(code:"), + "if (!slot.ended(ticket)) return@post", "retireActions()") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "private fun rendererGone("), + "page.lost()", "retireActions()", "slot.leave()") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "fun onStop()"), + "nt.suspendScroll()", "retireActions()", "slot.leave()", "webView?.onPause()") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "fun dispose()"), + "retireActions()", "slot.close()") + } + + @Test + fun `bridge input and scrolling use bounded admission and never launch independent RPCs`() { + val source = controller() + val input = AppSourcePins.blockAfter(source, "fun onInput(data:") + AppSourcePins.assertInOrder(input, "val s = stream ?: return", "if (!page.isCurrent(gen)) return", "writeInput(out, s)") + val scroll = AppSourcePins.blockAfter(source, "fun onScrollView(up:") + AppSourcePins.assertInOrder(scroll, "val input = actions ?: return", "if (!page.isCurrent(gen)) return", + "if (!input.scroll(up, notches, displayEpoch) && !input.scrollPaused) inputBusy()") + assertFalse(scroll.contains("launch"), "A suspended relay RPC must not run in a per-gesture coroutine") + val write = AppSourcePins.blockAfter(source, "private fun writeInput(") + AppSourcePins.assertInOrder(write, "val input = actions ?: return", "expected == null || stream !== expected", + "if (!input.write(data)) inputBusy()") + assertTrue(AppSourcePins.blockAfter(source, "private fun inputBusy()").contains("notice =")) + } + + @Test + fun `automatic reports keep their stream and queue while preserving Ctrl and pending scrolling`() { + val source = controller() + assertTrue(Regex("""@JavascriptInterface\s+fun onReport\(""").containsMatchIn(source), + "The release WebView must expose its automatic-report callback") + val report = AppSourcePins.blockAfter(source, "fun onReport(data:") + AppSourcePins.assertInOrder(report, "val s = stream ?: return", "if (!page.isCurrent(gen)) return", "writeReport(data, s)") + assertFalse(report.contains("ctrlArmed"), "Automatic replies must not consume an armed Ctrl chip") + assertFalse(report.contains("Keys.ctrl(")) + assertFalse(report.contains("cancelScroll")) + val write = AppSourcePins.blockAfter(source, "private fun writeReport(") + AppSourcePins.assertInOrder(write, "val input = actions ?: return", "stream !== expected", + "if (!input.report(data)) inputBusy()") + assertFalse(write.contains(".write("), "A report must not use the user-input scroll barrier") + } + + @Test + fun `a page scroll stop cancels only its captured viewer queue without sending input`() { + val source = controller() + assertTrue(Regex("""@JavascriptInterface\s+fun onScrollStop\(""").containsMatchIn(source)) + val stop = AppSourcePins.blockAfter(source, "fun onScrollStop()") + AppSourcePins.assertInOrder(stop, "val input = actions ?: return", "if (!page.isCurrent(gen)) return", "input.cancelScroll()") + assertFalse(stop.contains(".write("), "Stopping momentum must not type a synthetic key") + assertFalse(stop.contains(".close("), "The accepted viewer remains attached") + assertFalse(stop.contains("launch"), "A stop must be ordered with JavaBridge callbacks") + assertFalse(stop.contains("main.post"), "Do not delay a stop behind main-thread work") + } + + @Test + fun `native input cancels page momentum before the queue write and rechecks its stream`() { + val source = controller() + val raw = AppSourcePins.blockAfter(source, "fun raw(data:") + assertTrue(raw.contains("nt.raw("), "Raw chips must enter the page's scroll-cancelling input path") + assertFalse(raw.contains(".write(")) + val resume = AppSourcePins.blockAfter(source, "fun acceptResume()") + assertEquals(2, Regex("""writeAfterScrollCancel\(offer\.keys, s\)""").findAll(resume).count()) + val cancel = AppSourcePins.blockAfter(source, "private fun writeAfterScrollCancel(") + AppSourcePins.assertInOrder(cancel, "val wv = webView ?: return", "actions?.closeScrollView()", "wv.evaluateJavascript(\"nt.cancelScroll();nt.closeScrollView()\") {", + "webView === wv && stream === expected && attached", "writeInput(data, expected)") + assertFalse(Regex("""\b(?:stream|s)\??\.write\(""").containsMatchIn(source), + "Installed-viewer input must not bypass its ordered queue") + } + + @Test + fun `history callback checks viewer page and epoch inside the posted main runnable`() { + val attach = AppSourcePins.blockAfter(controller(), "private fun attach()") + AppSourcePins.assertInOrder(attach, "onScrollView = { result, epoch, displayEpoch ->", "main.post {", + "!slot.isCurrent(ticket)", "stream !== s", "actions !== actor", "webView !== view", + "!page.isCurrent(generation)", "actor.scrollEpoch != epoch", "when (result)", "nt.showScrollView(") + assertTrue(attach.contains("nt.scrollFailed($" + "displayEpoch)")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalClosedViewWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalClosedViewWiringTest.kt new file mode 100644 index 0000000000..102224acfb --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalClosedViewWiringTest.kt @@ -0,0 +1,41 @@ +package dev.nodeterm.protocol + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** App source pins only; TerminalJsClosedViewTest runs the actual parser/display behavior. */ +class TerminalClosedViewWiringTest { + @Test + fun `SSH viewer begins on the main thread before attachment`() { + val attach = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "private fun attach()") + AppSourcePins.assertInOrder(attach, "withContext(Dispatchers.Main.immediate)", + "slot.isCurrent(ticket)", "jsViewer(ticket, \"nt.beginViewer(", + "conn.kind == TransportKind.SSH", "val sink = sinkFor(ticket)", + "conn.attachManagedSession(", "conn.attach(nodeId") + val script = terminalScript() + assertTrue(AppSourcePins.blockAfter(script, "function beginViewer(").contains("sshTmux === true"), + "relay/native streams never implicitly opt into outer-tmux capture") + } + + @Test + fun `current EOF finishes output before its owned closed display and retirement`() { + val sink = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "private fun sinkFor(") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(sink, "override fun onExit("), + "slot.isCurrent(ticket)", "output.finish(ticket)", "jsViewer(ticket, \"nt.endViewer(", + "slot.ended(ticket)", "retireOutput()", "retireActions()") + } + + @Test + fun `background stop retires the same-page closed capture`() { + val stop = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "fun onStop()") + AppSourcePins.assertInOrder(stop, "stopped = true", "retireOutput()", + "jsPage(\"nt.suspendScroll()\")", "slot.leave()", "webView?.onPause()") + val script = terminalScript() + assertTrue(script.contains("suspendScroll: function () { retireViewer();")) + assertTrue(script.contains("window.addEventListener('pagehide', retireViewer)")) + } + + private fun terminalScript(): String = File(InteropHarness.repoRoot, + "android/app/src/main/assets/terminal/terminal.js").readText() +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalCopyTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalCopyTest.kt new file mode 100644 index 0000000000..209b1c4b7b --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalCopyTest.kt @@ -0,0 +1,171 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.ExternalLink +import dev.nodeterm.protocol.model.Osc52 +import dev.nodeterm.protocol.model.TerminalCopy +import dev.nodeterm.protocol.model.TerminalCopy.Selection +import dev.nodeterm.protocol.model.TerminalCopy.Text +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A32: what the phone opens and copies from its terminal. [ExternalLink] is the app's own gate on a + * link the page hands over (only http(s), with the host the "Open ?" offer names), and + * [TerminalCopy] reads the Copy sheet's snapshot and turns a selection into text under the clipboard + * cap the OSC 52 copy keeps (A53). The page's half is [TerminalJsLinksTest]. + */ +class TerminalCopyTest { + @Test + fun `http and https links are accepted, with the host the offer names`() { + assertEquals(ExternalLink("https://github.com/x/y?z=1#f", "github.com"), ExternalLink.parse("https://github.com/x/y?z=1#f")) + assertEquals("example.com", ExternalLink.parse("HTTP://Example.COM:8080/a")?.host) + assertEquals("[::1]", ExternalLink.parse("http://[::1]:3000/")?.host) + assertEquals("example.com", ExternalLink.parse("https://example.com")?.host) + assertEquals("example.com", ExternalLink.parse("https://example.com?q")?.host) + // A backslash past the authority is the URL parser's own spelling of a query. + assertEquals("a.example", ExternalLink.parse("https://a.example/?x=\\y")?.host) + } + + @Test + fun `the host named is where the link goes, not the user info in front of it`() { + assertEquals("evil.example", ExternalLink.parse("https://github.com@evil.example/login")?.host) + assertEquals("evil.example", ExternalLink.parse("https://user:pw@evil.example/")?.host) + } + + @Test + fun `everything that is not an http(s) URL with a host is refused`() { + val refused = listOf( + "", + "javascript:alert(1)", + "file:///etc/passwd", + "content://com.android.contacts/contacts", + "intent://scan/#Intent;scheme=zxing;end", + "ftp://example.com/x", + "data:text/html,x", + "mailto:a@b.example", + "http:/example.com", + "https://", + "https:///path", + "https://:443/", + "https://user@/x", + "http://[::1/", + "http://[::1]x/", + "http://example.com:80a/", + // Not as the page sends it: space, control and non-ASCII characters are always encoded. + "https://example.com/a b", + "https://example.com/\u0007", + "https://exämple.com/", + // A browser reads a backslash in the authority as a slash, so the host would be github.com + // while the offer named evil.example; the page's URL parser never sends one. + "https://github.com\\@evil.example/", + " https://example.com/", + ) + for (url in refused) assertNull(ExternalLink.parse(url), "accepted: $url") + } + + @Test + fun `a link longer than the cap is refused`() { + val base = "https://example.com/" + assertEquals("example.com", ExternalLink.parse(base + "a".repeat(ExternalLink.MAX_LENGTH - base.length))?.host) + assertNull(ExternalLink.parse(base + "a".repeat(ExternalLink.MAX_LENGTH - base.length + 1))) + } + + private fun snapshotJson(lines: List, links: List = emptyList(), firstVisible: Any? = 0) = buildJsonObject { + putJsonArray("lines") { lines.forEach { add(it.json()) } } + putJsonArray("links") { links.forEach { add(it.json()) } } + when (firstVisible) { + is Int -> put("firstVisible", firstVisible) + is String -> put("firstVisible", firstVisible) + else -> Unit + } + }.toString() + + private fun Any?.json() = when (this) { + null -> kotlinx.serialization.json.JsonNull + is String -> JsonPrimitive(this) + is Number -> JsonPrimitive(this) + else -> error("unsupported") + } + + @Test + fun `a snapshot keeps its lines and only the http(s) links, each once`() { + val snap = TerminalCopy.parse( + snapshotJson( + lines = listOf("a", "", "b"), + links = listOf("https://a.example/", "javascript:alert(1)", "https://a.example/", "file:///x", "http://b.example/", 7), + firstVisible = 1 + ) + ) + assertEquals( + TerminalCopy.Snapshot(listOf("a", "", "b"), listOf(ExternalLink("https://a.example/", "a.example"), ExternalLink("http://b.example/", "b.example")), 1), + snap + ) + } + + @Test + fun `a malformed snapshot is refused or degrades, never throws`() { + for (bad in listOf("", "not json", "[]", "{}", "{\"lines\":\"a\"}", "{\"lines\":null}")) assertNull(TerminalCopy.parse(bad), bad) + assertNull(TerminalCopy.parse("{\"lines\":[]," + " ".repeat(TerminalCopy.MAX_JSON) + "\"links\":[]}")) + // A wrong-typed line is empty, a wrong-typed or out-of-range firstVisible is clamped. + assertEquals(TerminalCopy.Snapshot(listOf("a", "", ""), emptyList(), 0), TerminalCopy.parse(snapshotJson(listOf("a", 3, null), firstVisible = "x"))) + assertEquals(2, TerminalCopy.parse(snapshotJson(listOf("a", "b", "c"), firstVisible = 99))?.firstVisible) + assertEquals(0, TerminalCopy.parse(snapshotJson(listOf("a"), firstVisible = -4))?.firstVisible) + assertEquals(TerminalCopy.Snapshot(emptyList(), emptyList(), 0), TerminalCopy.parse("{\"lines\":[]}")) + } + + @Test + fun `a snapshot keeps the last lines and links, and the visible line moves with the cut`() { + val lines = (0 until TerminalCopy.MAX_LINES + 10).map { "l$it" } + val links = (0 until TerminalCopy.MAX_LINKS + 5).map { "https://h$it.example/" } + val snap = TerminalCopy.parse(snapshotJson(lines, links, firstVisible = TerminalCopy.MAX_LINES + 3))!! + assertEquals(lines.takeLast(TerminalCopy.MAX_LINES), snap.lines) + assertEquals(links.takeLast(TerminalCopy.MAX_LINKS), snap.links.map { it.url }) + assertEquals("l${TerminalCopy.MAX_LINES + 3}", snap.lines[snap.firstVisible]) + } + + @Test + fun `a tap toggles a line and a long-press selects the range from the last line tapped`() { + var s = Selection() + s = s.toggle(3) + assertEquals(Selection(setOf(3), anchor = 3), s) + s = s.extendTo(6) + assertEquals(setOf(3, 4, 5, 6), s.selected) + // From the new anchor, upwards, added to what is selected. + s = s.toggle(9).extendTo(8) + assertEquals(setOf(3, 4, 5, 6, 8, 9), s.selected) + // A tap on a selected line deselects it and still anchors there. + s = s.toggle(4) + assertEquals(Selection(setOf(3, 5, 6, 8, 9), anchor = 4), s) + // A long-press with nothing tapped yet selects that line. + assertEquals(Selection(setOf(2), anchor = 2), Selection().extendTo(2)) + assertEquals(Selection(setOf(0, 1, 2)), s.all(3)) + assertEquals(Selection(), s.clear()) + } + + @Test + fun `the selected lines are copied in screen order, one per line`() { + val lines = listOf("one", "two", "", "four") + assertEquals(Text.Copy("one\n\nfour", 3), TerminalCopy.text(lines, Selection(setOf(3, 0, 2)))) + assertEquals(Text.Copy("two", 1), TerminalCopy.text(lines, Selection(setOf(1, 7, -1)))) + assertEquals(Text.Empty, TerminalCopy.text(lines, Selection())) + assertEquals(Text.Empty, TerminalCopy.text(lines, Selection(setOf(2)))) + assertEquals(Text.Empty, TerminalCopy.text(lines, Selection(setOf(9)))) + } + + @Test + fun `a selection over the clipboard cap is refused before any text is built`() { + val half = Osc52.MAX_TEXT_CHARS / 2 + // Two lines and the newline between them: exactly at the cap, then one over. + val atCap = listOf("a".repeat(half), "b".repeat(Osc52.MAX_TEXT_CHARS - half - 1)) + val copied = TerminalCopy.text(atCap, Selection(setOf(0, 1))) + assertEquals(Osc52.MAX_TEXT_CHARS, (copied as Text.Copy).text.length) + val over = listOf("a".repeat(half), "b".repeat(Osc52.MAX_TEXT_CHARS - half)) + assertEquals(Text.TooLarge, TerminalCopy.text(over, Selection(setOf(0, 1)))) + assertEquals(Text.TooLarge, TerminalCopy.text(listOf("x".repeat(Osc52.MAX_TEXT_CHARS + 1)), Selection(setOf(0)))) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalDraftsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalDraftsTest.kt new file mode 100644 index 0000000000..5ed0bb7896 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalDraftsTest.kt @@ -0,0 +1,139 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ComposedCompletion +import dev.nodeterm.protocol.host.ComposedInputResult +import dev.nodeterm.protocol.model.CtrlModifier +import dev.nodeterm.protocol.model.TerminalDrafts +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TerminalDraftsTest { + private data class Editor(val text: String = "", val start: Int = 0, val end: Int = start) + private val empty = Editor() + + @Test fun `covering a live entry retains its full editor selection revision and Ctrl`() { + val drafts = TerminalDrafts(empty) + val first = drafts.entry("terminal-1", "host-1") + first.edit(Editor("unsent Ω🧭 command", 2, 7)) + first.setCtrl(CtrlModifier().withArmed(true)) + val before = first.state.value + // The retained stack still contains this entry while its Pair screen is composed. + drafts.retain(setOf("terminal-1")) + val returned = drafts.entry("terminal-1", "host-1") + assertSame(first, returned) + assertSame(before, returned.state.value) + assertEquals(Editor("unsent Ω🧭 command", 2, 7), returned.state.value.value) + assertTrue(returned.state.value.ctrl.armed) + } + + @Test fun `different entries for the same host have independent drafts and modifiers`() { + val drafts = TerminalDrafts(empty) + val a = drafts.entry("a", "host") + val b = drafts.entry("b", "host") + a.edit(Editor("first", 3)); a.setCtrl(CtrlModifier().withArmed(true)) + b.edit(Editor("second", 1, 4)) + assertNotSame(a, b) + assertEquals(Editor("first", 3), a.state.value.value) + assertEquals(Editor("second", 1, 4), b.state.value.value) + assertTrue(a.state.value.ctrl.armed) + assertFalse(b.state.value.ctrl.armed) + } + + @Test fun `an edit away and back cannot be cleared by the earlier Send revision`() { + val drafts = TerminalDrafts(empty) + val entry = drafts.entry("a", "host") + entry.edit(Editor("command", 7)) + val sent = entry.state.value + entry.edit(Editor("other", 5)); entry.edit(sent.value) + assertFalse(entry.clearUnchangedDraft(sent.revision)) + assertEquals(sent.value, entry.state.value.value) + val latest = entry.state.value + assertTrue(entry.clearUnchangedDraft(latest.revision)) + assertEquals(empty, entry.state.value.value) + assertFalse(entry.clearUnchangedDraft(latest.revision), "the same completion clears once") + } + + @Test fun `identical editor reports advance the draft revision while Ctrl changes do not`() { + val entry = TerminalDrafts(empty).entry("a", "host") + entry.edit(Editor("command", 7)) + val sent = entry.state.value + entry.edit(sent.value) + assertEquals(sent.revision + 1, entry.state.value.revision) + val edited = entry.state.value + entry.setCtrl(edited.ctrl.withArmed(true)) + assertEquals(edited.revision, entry.state.value.revision) + assertFalse(entry.clearUnchangedDraft(sent.revision)) + assertTrue(entry.clearUnchangedDraft(edited.revision)) + assertTrue(entry.state.value.ctrl.armed, "clearing the draft cannot consume a new Ctrl arm") + } + + @Test fun `retiring a stack releases values and seals old references before a new entry mounts`() { + val drafts = TerminalDrafts(empty) + val old = drafts.entry("a", "host") + old.edit(Editor("secret command", 2)); old.setCtrl(CtrlModifier().withArmed(true)) + val sent = old.state.value + drafts.retain(emptySet()) + assertEquals(empty, old.state.value.value) + assertFalse(old.state.value.ctrl.armed) + assertFalse(old.edit(Editor("late dictation"))) + assertFalse(old.setCtrl(CtrlModifier().withArmed(true))) + assertFalse(old.clearUnchangedDraft(sent.revision)) + val replacement = drafts.entry("a", "host") + assertNotSame(old, replacement) + assertEquals(empty, replacement.state.value.value) + } + + @Test fun `forgetting one owner retires all of its entries and preserves another host`() { + val drafts = TerminalDrafts(empty) + val a = drafts.entry("a", "host-a"); val a2 = drafts.entry("a2", "host-a") + val b = drafts.entry("b", "host-b") + a.edit(Editor("a")); a2.edit(Editor("a2")); b.edit(Editor("b", 1)) + val beforeB = b.state.value + drafts.retireOwner("host-a") + assertEquals(empty, a.state.value.value); assertEquals(empty, a2.state.value.value) + assertFalse(a.edit(Editor("late"))); assertFalse(a2.edit(Editor("late"))) + assertSame(beforeB, b.state.value) + assertSame(b, drafts.entry("b", "host-b")) + assertNotSame(a, drafts.entry("a", "host-a")) + } + + @Test fun `a key reused with a different owner cannot adopt its earlier editor`() { + val drafts = TerminalDrafts(empty) + val old = drafts.entry("a", "host-a"); old.edit(Editor("first host")) + val next = drafts.entry("a", "host-b") + assertNotSame(old, next) + assertEquals(empty, next.state.value.value) + assertEquals(empty, old.state.value.value) + assertFalse(old.edit(Editor("late first host"))) + } + + @Test fun `retained large Unicode drafts are kept exactly without truncation or serialization`() { + val drafts = TerminalDrafts(empty) + val value = Editor("Ω🧭".repeat(300_000), 6, 899_997) + drafts.entry("a", "host").edit(value) + drafts.retain(setOf("a")) + assertSame(value, drafts.entry("a", "host").state.value.value) + // A new process has no draft store; command text was never serialized by this policy. + assertEquals(empty, TerminalDrafts(empty).entry("a", "host").state.value.value) + } + + @Test fun `a stale delivered receipt cannot clear the newer editor of a retained entry`() { + val drafts = TerminalDrafts(empty) + val entry = drafts.entry("a", "host") + entry.edit(Editor("sent", 4)); val sent = entry.state.value + val completion = ComposedCompletion(sent.ctrl.revision) + drafts.retain(setOf("a")); val returned = drafts.entry("a", "host") + returned.edit(Editor("next unsent", 2, 6)); returned.setCtrl(returned.state.value.ctrl.withArmed(true)) + var delivered = false + assertFalse(completion.complete(ComposedInputResult.DELIVERED, false, returned.state.value.ctrl.revision, + { returned.setCtrl(returned.state.value.ctrl.withArmed(false)) }, + { delivered = true; entry.clearUnchangedDraft(sent.revision) })) + assertFalse(delivered) + assertEquals(Editor("next unsent", 2, 6), returned.state.value.value) + assertTrue(returned.state.value.ctrl.armed) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalDraftsWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalDraftsWiringTest.kt new file mode 100644 index 0000000000..13c1e4aa25 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalDraftsWiringTest.kt @@ -0,0 +1,38 @@ +package dev.nodeterm.protocol + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Compose/navigation need a device; these pin delegation to the tested process-owned entry store. */ +class TerminalDraftsWiringTest { + @Test fun `the real navigation entry owns the editor and pending UI work is not restored`() { + val activity = AppSourcePins.app("MainActivity.kt") + val screen = AppSourcePins.ui("TerminalScreen.kt") + assertTrue(AppSourcePins.app("NodetermApp.kt").contains("val terminalDrafts = TerminalDrafts(TextFieldValue())")) + assertTrue(activity.contains("TerminalScreen(nav, top.key, r.hostId, r.nodeId, r.title)")) + AppSourcePins.assertInOrder(screen, "graph.terminalDrafts.entry(entryKey, hostId)", "entry.state.collectAsState()", + "remember(hostId, nodeId, entry)", "entry.state.value.ctrl", "entry::setCtrl") + assertFalse(screen.contains("var draft by remember")) + assertFalse(screen.contains("rememberSaveable"), "full command text must not be copied into a saved Activity Bundle") + val stop = AppSourcePins.blockAfter(screen, "LifecycleStartEffect(controller)") + assertTrue(stop.contains("controller.onStop()")) + assertTrue(AppSourcePins.blockAfter(screen, "DisposableEffect(controller)").contains("controller.dispose()")) + } + + @Test fun `Send captures the current accepted editor and clears through its entry revision`() { + val send = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalScreen.kt"), "val send: () -> Unit = {") + AppSourcePins.assertInOrder(send, "val sent = entry.beginSend()", "controller.submit(sent.value.text, enter = true,", + "entry.clearUnchangedDraft(sent.revision)", "dictation.edited()") + assertFalse(send.contains("controller.submit(draft.text"), "a collected UI value can lag a just accepted edit") + } + + @Test fun `all live entries survive coverage and removed owners and stacks retire their drafts`() { + val content = AppSourcePins.blockAfter(AppSourcePins.app("MainActivity.kt"), "private fun AppContent(") + AppSourcePins.assertInOrder(content, "SideEffect {", "nav.takeRetired()", "graph.terminalDrafts.retain(nav.entries.filter", + "route is Route.Terminal", "graph.hosts.get(route.hostId) != null", "it.key") + assertFalse(content.contains("terminalDrafts.retain(setOf(top.key))"), "covered entries are still alive") + val retirement = AppSourcePins.blockAfter(AppSourcePins.app("conn/ConnectionManager.kt"), "retire = { hostId, session ->") + AppSourcePins.assertInOrder(retirement, "session?.retire()", "graph.terminalDrafts.retireOwner(hostId)") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalExitTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalExitTest.kt new file mode 100644 index 0000000000..1d8c5d29b5 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalExitTest.kt @@ -0,0 +1,24 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.TerminalExit +import kotlin.test.Test +import kotlin.test.assertEquals + +class TerminalExitTest { + @Test + fun `a failed viewer does not report that the host session ended`() { + for (code in listOf(1, 137, 255, -1)) { + assertEquals("The terminal connection closed (exit $code).", TerminalExit.closedMessage(code)) + } + } + + @Test + fun `zero after the recovery budget is exhausted still describes only the viewer`() { + assertEquals("The terminal connection closed (exit 0).", TerminalExit.closedMessage(0)) + } + + @Test + fun `an unknown exit retains the existing disconnected notice`() { + assertEquals("Disconnected.", TerminalExit.closedMessage(null)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHandoffTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHandoffTest.kt new file mode 100644 index 0000000000..fb3cbfe508 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHandoffTest.kt @@ -0,0 +1,276 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.PhoneLaunch +import dev.nodeterm.protocol.host.StreamLease +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.host.ViewerSlot +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Audit A40: the terminal screen's attach hand-off, and the launch of a session the phone starts. + * The screen (TerminalController) is only type-checked; these pin the rules it delegates to. + */ +class TerminalHandoffTest { + private val log: MutableList = Collections.synchronizedList(ArrayList()) + + private inner class FakeStream(val name: String = "s") : TerminalStream { + override val fresh = true + override fun write(text: String) { + log += "$name write ${text.replace("\r", "\\r")}" + } + override fun resize(cols: Int, rows: Int) {} + override suspend fun scroll(up: Boolean, lines: Int) {} + override suspend fun detach() {} + override suspend fun endSession() { + log += "$name end" + } + } + + private fun lease(name: String = "s") = StreamLease(FakeStream(name)) { log += "$name detach" } + + // ---- (a) the hand-off re-checks the screen ---------------------------------------------- + + @Test + fun `a stream handed over after the screen was closed is detached, not installed`() { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + slot.close() // back pressed while the attach was on its way + assertFalse(slot.accept(t, l)) + assertNull(slot.stream) + assertEquals(listOf("s detach"), log) + } + + @Test + fun `a stream handed over after the screen went to the background is detached, not installed`() { + val slot = ViewerSlot() + val t = slot.begin() + slot.leave() + assertFalse(slot.accept(t, lease())) + assertEquals(listOf("s detach"), log) + } + + @Test + fun `a superseded attach cannot install its stream over the newer one`() { + val slot = ViewerSlot() + val old = slot.begin() + slot.leave() // background ... + val new = slot.begin() // ... and back: a second attach starts before the first one lands + val newer = lease("new") + assertTrue(slot.accept(new, newer)) + assertFalse(slot.isCurrent(old)) + assertFalse(slot.accept(old, lease("old"))) + assertSame(newer.stream, slot.stream) + assertEquals(listOf("old detach"), log) + } + + @Test + fun `the current hand-off installs, and leaving detaches it exactly once`() { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + assertTrue(slot.accept(t, l)) + assertSame(l.stream, slot.stream) + assertTrue(log.isEmpty()) + slot.leave() + slot.close() + l.release() + assertNull(slot.stream) + assertEquals(listOf("s detach"), log) + } + + @Test + fun `a stream that ended by itself is forgotten without a detach, and a late hand-off of it is refused`() { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + assertTrue(slot.accept(t, l)) + assertTrue(slot.ended(t)) + assertNull(slot.stream) + // The exit of a stream the screen already let go of (its own detach) is not the screen's. + assertFalse(slot.ended(t)) + slot.leave() + assertTrue(log.isEmpty()) + + // An exit that lands before its own hand-off: the dead stream must not be shown as attached. + val t2 = slot.begin() + assertTrue(slot.ended(t2)) + assertFalse(slot.accept(t2, lease("late"))) + assertNull(slot.stream) + } + + @Test + fun `the last of several holders detaches exactly once, whatever the threads`() { + repeat(50) { + val detaches = AtomicInteger() + val l = StreamLease(FakeStream()) { detaches.incrementAndGet() } + repeat(7) { assertTrue(l.acquire()) } + val go = CountDownLatch(1) + val threads = (0 until 8).map { + thread { + go.await() + l.release() + l.release() + } + } + go.countDown() + threads.forEach { it.join() } + assertEquals(1, detaches.get()) + assertFalse(l.acquire()) + } + } + + // ---- (b) a launch outlives the screen --------------------------------------------------- + + @Test + fun `leaving during the settle delay does not drop the launch, and the stream is detached after it`() = runBlocking { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + val settled = CompletableDeferred() + var outcome: PhoneLaunch.Outcome? = null + val job = PhoneLaunch.start(this, l, "claude --permission-mode auto", + register = { log += "register"; true }, + settle = { settled.await() }, + then = { outcome = it; log += "then" }) + assertTrue(slot.accept(t, l)) + yield() // the launch is now waiting for the shell to settle + + slot.close() // the user pressed back + assertTrue(log.isEmpty(), "the stream must stay attached until the launch is done: $log") + + settled.complete(Unit) + job.join() + assertEquals(listOf("s write claude --permission-mode auto\\r", "register", "s detach", "then"), log) + assertEquals(PhoneLaunch.Outcome.REGISTERED, outcome) + } + + @Test + fun `the app going to the background mid-launch does not drop it either`() = runBlocking { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + val settled = CompletableDeferred() + val job = PhoneLaunch.start(this, l, "bash", register = { log += "register"; true }, settle = { settled.await() }) + assertTrue(slot.accept(t, l)) + yield() + slot.leave() + // Back in the foreground: a new attach of the same session; the launch still holds the old stream. + val t2 = slot.begin() + assertTrue(slot.accept(t2, lease("new"))) + settled.complete(Unit) + job.join() + assertEquals(listOf("s write bash\\r", "register", "s detach"), log) + assertTrue(slot.isCurrent(t2)) + } + + @Test + fun `a launch whose hand-off was refused still runs, then detaches`() = runBlocking { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + val settled = CompletableDeferred() + // The attach thread starts the launch, then posts the hand-off; the screen closed in between. + val job = PhoneLaunch.start(this, l, "claude", register = { log += "register"; true }, settle = { settled.await() }) + slot.close() + assertFalse(slot.accept(t, l)) + assertTrue(log.isEmpty()) + settled.complete(Unit) + job.join() + assertEquals(listOf("s write claude\\r", "register", "s detach"), log) + } + + @Test + fun `a launch on a stream the screen still shows leaves it attached`() = runBlocking { + val slot = ViewerSlot() + val t = slot.begin() + val l = lease() + val job = PhoneLaunch.start(this, l, "claude", register = { log += "register"; true }, settle = {}) + assertTrue(slot.accept(t, l)) + job.join() + assertEquals(listOf("s write claude\\r", "register"), log) + assertSame(l.stream, slot.stream) + slot.close() + assertEquals(listOf("s write claude\\r", "register", "s detach"), log) + } + + @Test + fun `a refusal and a failed registration are both REFUSED, a connection without the verb is CANNOT_REGISTER`() = runBlocking { + suspend fun run(register: (suspend () -> Boolean)?): PhoneLaunch.Outcome? { + var outcome: PhoneLaunch.Outcome? = null + val l = lease() + val job = PhoneLaunch.start(this, l, null, register, settle = {}, then = { outcome = it }) + l.release() // the screen left + job.join() + return outcome + } + assertEquals(PhoneLaunch.Outcome.REFUSED, run { false }) + assertEquals(PhoneLaunch.Outcome.REFUSED, run { throw IllegalStateException("host gone") }) + assertEquals(PhoneLaunch.Outcome.CANNOT_REGISTER, run(null)) + // No command (a plain shell): nothing typed, the stream still let go of each time. + assertEquals(listOf("s detach", "s detach", "s detach"), log) + } + + @Test + fun `a write that throws does not keep the stream attached or skip the registration`() = runBlocking { + val l = StreamLease(object : TerminalStream { + override val fresh = true + override fun write(text: String) = throw IllegalStateException("socket closed") + override fun resize(cols: Int, rows: Int) {} + override suspend fun scroll(up: Boolean, lines: Int) {} + override suspend fun detach() {} + override suspend fun endSession() {} + }) { log += "detach" } + var outcome: PhoneLaunch.Outcome? = null + val job = PhoneLaunch.start(this, l, "claude", register = { log += "register"; true }, settle = {}, then = { outcome = it }) + l.release() // the screen left + job.join() + assertEquals(listOf("register", "detach"), log) + assertEquals(PhoneLaunch.Outcome.REGISTERED, outcome) + } + + @Test + fun `a launch cannot start on a stream that was already let go of`() { + val l = lease() + l.release() + assertFailsWith { + runBlocking { PhoneLaunch.start(this, l, "claude", register = null, settle = {}) } + } + } + + // ---- (c) going to the background never drops an attach that is on the wire --------------- + + @Test + fun `the screen's attach is not cancelled once it is sent, so the hand-off and the launch always run`() { + // ON_STOP cancels the attach job. Cancelled inside conn.attach, the attach threw away a + // stream the host had already reserved (and was creating the session for) and never took the + // pending launch. What the screen does from the request to the hand-off must not be + // cancellable; only a device runs the controller, so its source is pinned. + val body = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "fun attach()") + AppSourcePins.assertInOrder( + body, + "ensureActive()", + "withContext(NonCancellable) {", + "conn.attach(nodeId, c, r, sink, hint)", + "PendingLaunches.take(nodeId)", + "startLaunch(launch, lease, conn)", + "slot.accept(ticket, lease)", + "s to launch" + ) + // A superseded attach's dial must not put its approval code over the current screen. + AppSourcePins.assertInOrder(body, "RelayConnectStatus.AwaitingApproval", "slot.isCurrent(ticket)", "TermState.AwaitingApproval(") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHistoryTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHistoryTest.kt new file mode 100644 index 0000000000..369ad00db3 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHistoryTest.kt @@ -0,0 +1,35 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.J +import dev.nodeterm.protocol.model.TerminalHistory as H +import kotlin.test.* + +class TerminalHistoryTest { + @Test fun `SSH framing preserves tabs Unicode and literal query syntax`() { + val result = H.parseSsh("NT-HISTORY-1\t999\t0\n0\told.* Ω 😀\n998\ttab\t.* Ω 😀\n", ".* Ω 😀")!! + assertEquals(999, result.searchedLines) + assertEquals(listOf(H.Row(0, "old.* Ω 😀"), H.Row(998, "tab\t.* Ω 😀")), result.rows) + assertFalse(result.truncated) + assertTrue(H.parseSsh("NT-HISTORY-1\t999\t1\n", "x")!!.truncated) + } + @Test fun `malformed incomplete stale and oversized results are refused`() { + for (text in listOf("", "NT-HISTORY-1\t-1\t0\n", "NT-HISTORY-1\t2\t2\n", "NT-HISTORY-1\t2\t0\n2\tx\n", "NT-HISTORY-1\t2\t0\n0\tx\n0\tx\n", "NT-HISTORY-1\t2\t0\n0\ty\n", "NT-HISTORY-1\t2\t0\n0\tX\n")) assertNull(H.parseSsh(text, "x"), text) + assertNull(H.parseSsh("NT-HISTORY-1\t201\t0\n" + (0..200).joinToString("") { "$it\tx\n" }, "x")) + assertNull(H.parseSsh("NT-HISTORY-1\t1\t0\n0\t" + "😀".repeat(70_000) + "x\n", "x")) + val oversized = "😀".repeat(70_000) + "x" + assertNull(H.parseRelay(J.parse("""{"searchedLines":1,"truncated":false,"rows":[{"line":0,"text":"$oversized"}]}"""), "x")) + assertNull(H.parseRelay(J.parse("""{"searchedLines":1,"truncated":false,"rows":[{"line":0,"text":"y"}]}"""), "x")) + assertNull(H.parseRelay(J.parse("""{"searchedLines":1,"rows":[]}"""), "x")) + assertNull(H.parseRelay(J.parse("""{"searchedLines":1.5,"truncated":false,"rows":[]}"""), "x")) + assertNull(H.parseRelay(J.parse("""{"searchedLines":1,"truncated":false,"rows":[{"line":0.5,"text":"x"}]}"""), "x")) + } + @Test fun `query validation preserves literal spaces and bounds single line input`() { + assertTrue(H.validQuery(" ")) + assertTrue(H.validQuery("'\"`$(x).*😀")) + for (q in listOf("", "x\ny", "\r", "\u0000", "\u007f", "x".repeat(257))) assertFalse(H.validQuery(q)) + } + @Test fun `relay parses the same rows and truncation facts as SSH`() { + val json = J.parse("""{"searchedLines":999,"truncated":true,"rows":[{"line":0,"text":"x Ω"},{"line":998,"text":"x"}]}""") + assertEquals(H.parseSsh("NT-HISTORY-1\t999\t1\n0\tx Ω\n998\tx\n", "x"), H.parseRelay(json, "x")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHistoryWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHistoryWiringTest.kt new file mode 100644 index 0000000000..66a301ae35 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalHistoryWiringTest.kt @@ -0,0 +1,26 @@ +package dev.nodeterm.protocol + +import kotlin.test.* + +class TerminalHistoryWiringTest { + @Test fun `native Find searches host history and keeps result requests separate from terminal input`() { + val sheet = AppSourcePins.ui("TerminalHistorySheet.kt") + assertTrue("LaunchedEffect(request)" in sheet) + AppSourcePins.assertInOrder(sheet, "val ticket = request", "val literal = submitted", "controller.searchHistory(literal)") + assertTrue("if (request == ticket) result = answer" in sheet) + assertTrue("finally { if (request == ticket) searching = false }" in sheet) + assertTrue("catch (cancelled: CancellationException) { throw cancelled }" in sheet) + assertTrue("Match case; spaces are significant." in sheet) + assertTrue("found.truncated" in sheet && "Some matches are omitted" in sheet) + assertFalse("controller.raw(" in sheet || "controller.key(" in sheet) + } + @Test fun `history completion refuses a replaced stopped or disposed stream`() { + val search = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "suspend fun searchHistory(") + AppSourcePins.assertInOrder(search, "val expected = stream", "expected.searchHistory(query)", "stream !== expected || disposed || stopped") + } + @Test fun `leaving or backgrounding the screen closes history before detaching its stream`() { + val controller = AppSourcePins.ui("TerminalController.kt") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(controller, "fun onStop()"), "closeHistory()", "slot.leave()") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(controller, "fun dispose()"), "closeHistory()", "slot.close()") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsClosedViewTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsClosedViewTest.kt new file mode 100644 index 0000000000..f0c643e2b9 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsClosedViewTest.kt @@ -0,0 +1,91 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Actual shipped xterm parses native byte fixtures. This is a jsdom component test, not device QA. */ +class TerminalJsClosedViewTest { + @Test + fun `real xterm preserves closed SSH rows without changing its live parser`() { + val root = InteropHarness.repoRoot + val driver = File(root, "android/protocol/src/test/interop/terminal-closed-view-driver.cjs") + val asset = File(root, "android/app/src/main/assets/terminal/terminal.js") + val logs = Files.createTempDirectory("nodeterm-closed-view-").toFile() + val stdout = File(logs, "stdout.json") + val stderr = File(logs, "stderr.txt") + // Missing Node/jsdom fails this required regression; it must not become an assumed skip. + val process = try { + ProcessBuilder("node", driver.path, asset.path).directory(root) + .redirectOutput(stdout).redirectError(stderr).start() + } catch (error: Exception) { + logs.deleteRecursively() + throw AssertionError("node + npm ci (jsdom) are required for actual-xterm regressions", error) + } + val reply = try { + assertTrue(process.waitFor(30, TimeUnit.SECONDS), "actual-xterm closed-view driver timed out") + assertTrue(stdout.length() in 1..1_048_576, "bounded driver stdout") + assertTrue(stderr.length() <= 65_536, "bounded driver stderr") + assertEquals(0, process.exitValue(), "actual-xterm closed-view driver failed: ${stderr.readText()}") + Json.parseToJsonElement(stdout.readText().trim()).jsonObject + } finally { + if (process.isAlive) { + process.destroyForcibly() + process.waitFor(2, TimeUnit.SECONDS) + } + logs.deleteRecursively() + } + assertEquals(1, reply["schema"]!!.jsonPrimitive.int) + assertTrue(reply["passed"]!!.jsonPrimitive.boolean) + assertEquals("actual shipped xterm5.5", reply["parser"]!!.jsonPrimitive.content) + assertFalse(reply["physicalAcceptance"]!!.jsonPrimitive.boolean) + assertFalse(reply["sshRuntimeAcceptance"]!!.jsonPrimitive.boolean) + assertFalse(reply["transientPaintTimingAcceptance"]!!.jsonPrimitive.boolean) + val cases = reply["cases"]!!.jsonArray.map { it.jsonObject } + val names = cases.map { it["name"]!!.jsonPrimitive.content } + val expected = listOf( + "immediate real suffix coalesced", + "immediate real suffix byte splits", + "delayed real suffix coalesced", + "delayed real suffix byte splits", + "relay/native opt-out uses unchanged live parser", + "queued EOF cancelled by begin", + "queued EOF cancelled by pagehide", + "queued EOF cancelled by suspend", + "live ED2 repaint and later teardown retain current pane", + "latest blank ED2 replaces an older populated capture", + "drop without outer mode restore keeps active pane unchanged", + "Unicode wrap Copy and OSC8/plain URL touch are captured without input", + "closed user-origin event cannot turn next viewer DA into typed input", + "closed display invalidates on paint", + "closed display invalidates on reset", + "settled closed rows and Copy survive refit", + "settled closed rows and Copy survive setFontSize", + "settled closed rows and Copy survive resize", + "geometry invalidates only a pending EOF promotion", + "old closed link touch cannot activate a successor snapshot", + "same-view JS submit keeps separated Enter", + "same-view delayed Enter survives refit font and resize barriers", + "delayed JS Enter cannot cross begin", + "delayed JS Enter cannot cross retire", + "settled snapshot redraw uses changed layout and font for its owned link touch", + "stationary closed touch on wrapped plain URL offers the exact complete URL", + "generated tmux TUI repaint keeps live alternate modes and reports before EOF", + ) + assertEquals(27, cases.size, "every required case must execute") + assertEquals(names.size, names.toSet().size, "case names must be unique") + assertEquals(expected, names, "exact ordered component inventory") + for (case in cases) assertTrue(case["passed"]!!.jsonPrimitive.boolean, + "${case["name"]!!.jsonPrimitive.content}: $case") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsDriver.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsDriver.kt new file mode 100644 index 0000000000..a2f1e90d01 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsDriver.kt @@ -0,0 +1,36 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Runs the app's real `terminal.js` in node against stub xterm/DOM/bridge objects, through + * `src/test/interop/terminal-js-driver.cjs` (its header documents the request and the reply). Skips + * the calling test when there is no node. + */ +object TerminalJsDriver { + fun run(request: JsonObject): JsonObject { + val nodeOk = runCatching { ProcessBuilder("node", "--version").start().waitFor() == 0 }.getOrDefault(false) + assumeTrue(nodeOk, "node is needed to run terminal.js") + val root = InteropHarness.repoRoot + val script = File(root, "android/app/src/main/assets/terminal/terminal.js") + val driver = File(root, "android/protocol/src/test/interop/terminal-js-driver.cjs") + val proc = ProcessBuilder("node", driver.path, script.path).directory(root).start() + proc.outputStream.bufferedWriter().use { it.write(request.toString()) } + val out = proc.inputStream.bufferedReader().readText() + val err = proc.errorStream.bufferedReader().readText() + assertTrue(proc.waitFor(30, TimeUnit.SECONDS), "driver timed out") + assertEquals(0, proc.exitValue(), "driver failed: $err") + val reply = Json.parseToJsonElement(out.trim()).jsonObject + reply["error"]?.let { fail(it.jsonPrimitive.content) } + return reply + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsHistoryViewTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsHistoryViewTest.kt new file mode 100644 index 0000000000..7ef03732d3 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsHistoryViewTest.kt @@ -0,0 +1,85 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.* +import kotlin.test.* + +class TerminalJsHistoryViewTest { + private fun row(text: String, wrapped: Boolean = false, section: String = "normal") = buildJsonObject { + put("text", text); put("isWrapped", wrapped); put("section", section) + } + private fun page(vararg rows: JsonObject) = buildJsonObject { + put("status", "history"); put("viewId", "b3b8e879-6449-4c8c-8529-4c02d6885878") + put("offset", 4); put("totalRows", rows.size + 4); put("cols", 20) + put("olderTruncated", true); put("hasOlder", false); put("hasNewer", true) + put("rows", JsonArray(rows.toList())) + } + private fun run(build: JsonObjectBuilder.() -> Unit) = TerminalJsDriver.run(buildJsonObject { + put("copyLimit", 8) + put("screen", buildJsonObject { put("cols", 20); put("rows", 10) + put("lines", buildJsonArray { add(buildJsonObject { put("text", "LIVE https://live.x/") }) }) }) + build() + }) + private fun history(result: JsonObject) = result["history"]!!.jsonObject + private fun actionPage(page: JsonObject, epoch: Int? = null) = buildJsonObject { put("history", page); epoch?.let { put("epoch", it) } } + private fun action(fn: String, vararg args: JsonPrimitive) = buildJsonObject { put("nt", fn); put("args", JsonArray(args.toList())) } + private fun copy(result: JsonObject) = Json.parseToJsonElement(result["copySheet"]!!.jsonObject["raw"]!!.jsonPrimitive.content).jsonObject + @Test fun `history is inert displayed text while live bytes continue without resets`() { + val page = page(row(""), row("https://old.example/")) + val result = run { + put("actions", buildJsonArray { add(actionPage(page)); add(action("write", JsonPrimitive("bGl2ZSBvdXRwdXQ="))) }) + put("copySheet", true) + } + assertFalse(history(result)["hidden"]!!.jsonPrimitive.boolean) + assertEquals(listOf("", "https://old.example/"), history(result)["rows"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertEquals(listOf("live output"), result["liveWrites"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertEquals(0, result["liveResets"]!!.jsonPrimitive.int) + assertEquals(listOf("https://old.example/"), copy(result)["links"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + @Test fun `links and copying use displayed physical wraps and section boundaries`() { + val page = page(row("https://example.com/a"), row("older", true), row("ALT", true, "alternate")) + val result = run { + put("actions", buildJsonArray { add(actionPage(page)) }) + put("taps", buildJsonArray { add(buildJsonObject { put("col", 3); put("row", 1) }) }) + put("copySheet", true) + } + assertEquals(listOf("https://example.com/aolder"), result["taps"]!!.jsonArray[0].jsonObject["opened"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertEquals(listOf("https://example.com/aolder", "ALT"), copy(result)["lines"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + @Test fun `a response arriving mid drag keeps continuous movement and reversal`() { + val result = run { + put("historyResponses", buildJsonArray { add(page(row("older"))) }) + put("taps", buildJsonArray { add(buildJsonObject { + put("col", 3); put("row", 1) + put("moves", buildJsonArray { + add(buildJsonArray { add(JsonPrimitive(0)); add(JsonPrimitive(200)) }) + add(buildJsonArray { add(JsonPrimitive(0)); add(JsonPrimitive(-100)) }) + }) + put("moveActions", buildJsonArray { add(buildJsonArray { add(buildJsonObject { put("frame", true) }) }) }) + }) }) + } + val scrolls = result["taps"]!!.jsonArray[0].jsonObject["scrolls"]!!.jsonArray + assertEquals(listOf(true to 10, false to 15), scrolls.map { it.jsonArray[0].jsonPrimitive.boolean to it.jsonArray[1].jsonPrimitive.int }) + assertFalse(history(result)["hidden"]!!.jsonPrimitive.boolean) + } + @Test fun `old delivered pages cannot reopen after input live exit or lifecycle`() { + val captured = page(row("old")) + val barriers = listOf(action("raw", JsonPrimitive("Aw==")), action("key", JsonPrimitive("esc")), + action("suspendScroll"), buildJsonObject { put("data", "x") }, buildJsonObject { put("live", true) }) + for (barrier in barriers) { + val result = run { put("actions", buildJsonArray { add(actionPage(captured, 0)); add(barrier); add(actionPage(captured, 0)) }) } + assertTrue(history(result)["hidden"]!!.jsonPrimitive.boolean, "$barrier must invalidate a page already queued in WebView") + assertEquals(0, result["liveResets"]!!.jsonPrimitive.int) + } + } + @Test fun `automatic reports leave the displayed history in place`() { + val result = run { put("actions", buildJsonArray { add(actionPage(page(row("old")))); add(buildJsonObject { put("report", "\u001b[2;3R") }) }) } + assertFalse(history(result)["hidden"]!!.jsonPrimitive.boolean) + } + @Test fun `copied soft wraps preserve physical cell padding`() { + val result = run { + put("actions", buildJsonArray { add(actionPage(page(row("x"), row("end", true)))) }) + put("copySheet", true) + } + assertEquals(listOf("x" + " ".repeat(19) + "end"), copy(result)["lines"]!!.jsonArray.map { it.jsonPrimitive.content }) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsKineticTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsKineticTest.kt new file mode 100644 index 0000000000..4e52d65a52 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsKineticTest.kt @@ -0,0 +1,138 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TerminalJsKineticTest { + private fun run( + start: Int = 10, + end: Int = 95, + moves: List> = listOf(listOf(0, 100, 50), listOf(0, 200, 90)), + configure: JsonObjectBuilder.() -> Unit = {}, + ): JsonObject = TerminalJsDriver.run(buildJsonObject { + put("copyLimit", 8) + put("screen", buildJsonObject { put("cols", 52); put("rows", 45); put("cellHeight", 20) }) + putJsonArray("taps") { + add(buildJsonObject { + put("col", 2); put("row", 3); put("startTime", start); put("endTime", end) + putJsonArray("moves") { moves.forEach { move -> add(buildJsonArray { move.forEach { add(JsonPrimitive(it)) } }) } } + configure() + }) + } + })["taps"]!!.jsonArray.single().jsonObject + + private fun scrolls(result: JsonObject) = result["scrolls"]!!.jsonArray.map { + it.jsonArray[0].jsonPrimitive.boolean to it.jsonArray[1].jsonPrimitive.int + } + private fun distance(result: JsonObject) = scrolls(result).sumOf { it.second } + private fun stops(result: JsonObject) = result["scrollStops"]!!.jsonArray + private fun action(fn: String, vararg args: String) = buildJsonObject { + put("nt", fn); putJsonArray("args") { args.forEach { add(JsonPrimitive(it)) } } + } + + @Test + fun `fast release coasts after the finger lifts and slows to a bounded stop`() { + val result = run() + assertTrue(distance(result) > 10, "the finger requested ten notches; release keeps moving") + assertTrue(distance(result) <= 10 + 1200 / 20, "additional distance is bounded") + assertTrue(result["endedAt"]!!.jsonPrimitive.int - 95 <= 1016, "the fling ends within its lifetime") + assertTrue(scrolls(result).all { it.first && it.second in 1..20 }) + assertEquals(1, stops(result).size, "natural decay never cancels accepted native movement") + val frames = result["scrollFrames"]!!.jsonArray.map { it.jsonObject["frame"]!!.jsonPrimitive.int } + assertEquals(frames.distinct(), frames, "one wheel request per frame") + assertTrue(frames.last() - frames[frames.lastIndex - 1] > frames[2] - frames[1], + "late wheel notches become further apart as motion decays") + } + + @Test + fun `decay follows elapsed time at different frame rates and survives a zero first delta`() { + val totals = listOf(120, 60, 30).map { hz -> + distance(run { put("rafInterval", 1000.0 / hz); put("frameDelay", 1000.0 / hz) }) + } + assertTrue(totals.max() - totals.min() <= 1, "time-based motion keeps distance across frame rates: $totals") + assertTrue(distance(run { put("frameDelay", 0) }) > 10, "a same-frame release still starts on the next frame") + } + + @Test + fun `slow stale untimed and stationary releases do not synthesize a fling`() { + assertEquals(2, distance(run(end = 215, moves = listOf(listOf(0, 20, 110), listOf(0, 40, 210))))) + assertEquals(10, distance(run(end = 185))) + assertEquals(10, distance(run(start = 0, end = 0, moves = listOf(listOf(0, 200, 0))))) + assertEquals(10, distance(run(end = 10, moves = listOf(listOf(0, 200, 10))))) + assertEquals(0, distance(run(end = 55, moves = listOf(listOf(0, 8, 50))))) + } + + @Test + fun `a reversal coasts in the last direction while preserving earlier queued movement`() { + val result = run(end = 75, moves = listOf(listOf(0, 100, 50), listOf(0, 60, 70))) + val requests = scrolls(result) + assertEquals(true to 5, requests.first()) + assertTrue(requests.drop(1).all { !it.first }, "the release continues the final upward finger motion") + assertTrue(requests.drop(1).sumOf { it.second } > 2, "the reversal has its own recent velocity") + } + + @Test + fun `new touch cancels momentum and native pending movement before starting again`() { + val result = run { + putJsonArray("after") { + add(buildJsonObject { put("frames", 2) }) + add(buildJsonObject { put("event", "touchstart") }) + add(buildJsonObject { put("event", "touchmove"); put("move", buildJsonArray { add(JsonPrimitive(0)); add(JsonPrimitive(-40)) }) }) + } + } + assertEquals(15, distance(result), "thirteen emitted old notches plus the two new reverse notches") + assertEquals(false to 2, scrolls(result).last(), "new movement cannot reawaken the retired fling") + assertEquals(2, stops(result).size) + assertEquals(2, stops(result).last().jsonObject["frame"]!!.jsonPrimitive.int, + "the interruption reaches the native queue immediately") + assertEquals(3, result["scrollFrames"]!!.jsonArray.last().jsonObject["frame"]!!.jsonPrimitive.int, + "native stop precedes the fresh gesture's delivery") + } + + @Test + fun `input reset font and page barriers stop a coast but reports preserve it`() { + val barriers = listOf(action("reset"), action("paint", "eA=="), action("key", "esc"), + action("raw", "Gw=="), action("submit", "eA=="), action("setFontSize", "20"), + buildJsonObject { put("data", "x") }) + listOf("blur", "pagehide", "hidden", "touchcancel", "multitouch") + .map { buildJsonObject { put("event", it) } } + for (barrier in barriers) { + val result = run { + putJsonArray("after") { add(buildJsonObject { put("frames", 2) }); add(barrier) } + } + assertEquals(13, distance(result), "coast stops after $barrier") + assertTrue(stops(result).size >= 2, "$barrier clears the native movement too") + } + val reported = run { + putJsonArray("after") { + add(buildJsonObject { put("frames", 2) }) + add(buildJsonObject { put("report", "\u001b[1;1R") }) + add(buildJsonObject { put("mouse", "\u001b[<35;3;15M") }) + } + } + assertEquals(distance(run()), distance(reported)) + assertEquals(1, stops(reported).size, "reports do not stop momentum") + } + + @Test + fun `extreme sample speed stays bounded and a suspended animation is discarded`() { + val fast = run(end = 12, moves = listOf(listOf(0, 200, 11))) + assertTrue(distance(fast) in 11..46, "the speed clamp limits extra distance to at most 3 px/ms × 240 ms") + assertTrue(fast["endedAt"]!!.jsonPrimitive.int - 12 <= 1016) + val suspended = run { put("frameDelay", 251) } + assertEquals(0, distance(suspended)) + assertEquals(2, stops(suspended).size, "stale animation also clears native movement") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsLinksTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsLinksTest.kt new file mode 100644 index 0000000000..6363a13fa5 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsLinksTest.kt @@ -0,0 +1,308 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.ExternalLink +import dev.nodeterm.protocol.model.TerminalCopy +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * A32: links and copying in the phone's terminal, run in the app's real terminal.js + * ([TerminalJsDriver]) against a stub xterm buffer. + * + * - A URL is matched across the rows it wraps over, both xterm's soft wraps and the full-width rows a + * tmux repaint or an agent's fullscreen TUI paints with no wrap flag (the desktop's file-links.ts, + * ported). Matched row by row, a long OAuth URL opened only its first row's fragment. + * - A tap on a link hands it to the bridge's openUrl and cancels the touchend, so the click the tap + * would make never reaches tmux (it runs `mouse on`); any other tap is left alone. + * - Only http(s) reaches the bridge, from every way in: a tap, the link provider, and OSC 8 links + * through `options.linkHandler` (without one, xterm asks with confirm(), which the WebView cannot show). + * - The Copy sheet gets the buffer's lines (soft wraps joined) and its links, in a shape [TerminalCopy] + * reads. + * + * Whether a tap on a phone produces exactly these events, and the app's Android half (the "Open + * ?" offer, the intent, the sheet), are left to the device checklist. Skips without node. + */ +class TerminalJsLinksTest { + private data class Line(val text: String, val wrapped: Boolean = false, val links: List> = emptyList()) + private data class Tap(val col: Int, val row: Int, val move: Pair? = null, val fingers: Int = 1) + private data class Tapped(val prevented: Boolean, val opened: List) + + private fun screen(cols: Int, rows: Int, lines: List, viewportY: Int? = null) = buildJsonObject { + put("cols", cols) + put("rows", rows) + if (viewportY != null) put("viewportY", viewportY) + putJsonArray("lines") { + for (l in lines) add( + buildJsonObject { + put("text", l.text) + put("wrapped", l.wrapped) + putJsonArray("links") { + for ((from, to, uri) in l.links) add(buildJsonObject { put("from", from); put("to", to); put("uri", uri) }) + } + } + ) + } + } + + private fun run(screen: JsonObject, build: JsonObjectBuilder.() -> Unit): JsonObject = + TerminalJsDriver.run( + buildJsonObject { + put("copyLimit", 8) + put("screen", screen) + build() + } + ) + + private fun taps(screen: JsonObject, vararg taps: Tap): List { + val reply = run(screen) { + putJsonArray("taps") { + for (t in taps) add( + buildJsonObject { + put("col", t.col) + put("row", t.row) + if (t.move != null) put("move", buildJsonArray { add(JsonPrimitive(t.move.first)); add(JsonPrimitive(t.move.second)) }) + put("fingers", t.fingers) + } + ) + } + } + assertEquals(0, reply["confirmCalls"]!!.jsonPrimitive.int, "terminal.js reached xterm's confirm()") + return reply["taps"]!!.jsonArray.map { r -> + val o = r.jsonObject + Tapped(o["prevented"]!!.jsonPrimitive.boolean, o["opened"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + } + + private fun opened(url: String) = Tapped(prevented = true, opened = listOf(url)) + private val passedOn = Tapped(prevented = false, opened = emptyList()) + + // A 20-column screen holding a URL that a tmux repaint split over three full-width rows: no row + // carries a wrap flag, each continuing row is full to its last column, and the next starts at 0. + private val oauth = "https://example.com/oauth/authorize?client=abc" + private val repainted = screen( + cols = 20, + rows = 6, + lines = listOf( + Line("Open: https://exampl"), // 20 columns + Line("e.com/oauth/authoriz"), // 20 columns + Line("e?client=abc"), + Line("not a link here"), + ) + ) + + @Test + fun `a URL repainted over several full-width rows opens whole, from a tap on any of its rows`() { + // The first row's fragment alone ("https://exampl") is what a row-by-row match opened. + val results = taps(repainted, Tap(col = 8, row = 0), Tap(col = 3, row = 1), Tap(col = 5, row = 2)) + assertEquals(List(3) { opened(oauth) }, results) + } + + @Test + fun `a URL xterm soft-wrapped opens whole`() { + val soft = screen( + cols = 20, + rows = 4, + lines = listOf( + // A soft wrap need not fill the row: the flag says the next row continues it. + Line("x https://example.co"), + Line("m/a/b/c d", wrapped = true), + ) + ) + assertEquals(listOf(opened("https://example.com/a/b/c"), opened("https://example.com/a/b/c")), taps(soft, Tap(4, 0), Tap(2, 1))) + } + + // 70 rows of a 20-column screen that each run into the next by the repaint heuristic (full to the + // last column, the next starting at column 0), as a TUI's bordered box paints them, with a URL on + // row 40 below 40 such rows. The desktop's paragraphContaining walked up 32 rows and joined only + // the 32 above row 40, so the paragraph "containing" it did not, and a scan that steps from one + // paragraph to the next stood still on it. + private val wall = screen( + cols = 20, + rows = 5, + lines = List(40) { Line("-".repeat(20)) } + Line("https://a.example/b") + List(29) { Line("-".repeat(20)) }, + viewportY = 38 + ) + + @Test + fun `a URL under a wall of continuing rows is still found by a tap`() { + assertEquals(listOf(opened("https://a.example/b")), taps(wall, Tap(col = 3, row = 2))) + } + + @Test + fun `the Copy sheet gets through a wall of continuing rows`() { + // Without the fix this never returned: the driver times out, and on the phone the page hangs. + val (json, _) = copySheet(wall) + assertEquals(70, json["lines"]!!.jsonArray.size) + assertEquals(listOf("https://a.example/b"), json["links"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + + @Test + fun `a tap off a link, a swipe, and a second finger reach tmux untouched`() { + val results = taps( + repainted, + Tap(col = 2, row = 0), // "Open:" before the URL + Tap(col = 4, row = 3), // plain text + Tap(col = 15, row = 2), // past the end of the URL + Tap(col = 8, row = 0, move = 0 to 40), // a swipe that starts on the URL scrolls instead + Tap(col = 8, row = 0, move = 11 to 0), // just past the slop + Tap(col = 8, row = 0, fingers = 2), + ) + assertEquals(List(6) { passedOn }, results) + } + + @Test + fun `a tap that wobbles within the slop is still a tap`() { + assertEquals(listOf(opened(oauth)), taps(repainted, Tap(col = 8, row = 0, move = 6 to -6))) + } + + @Test + fun `a tap on an OSC 8 link opens the URL behind its label, and only an http(s) one`() { + val osc8 = screen( + cols = 30, + rows = 3, + lines = listOf( + Line("see the docs here", links = listOf(Triple(8, 11, "https://docs.example.com/guide"))), + Line("run evil now", links = listOf(Triple(4, 7, "javascript:alert(1)"))), + Line("a local file", links = listOf(Triple(2, 6, "file:///etc/passwd"))), + ) + ) + val results = taps(osc8, Tap(9, 0), Tap(5, 1), Tap(3, 2), Tap(0, 0)) + assertEquals(listOf(opened("https://docs.example.com/guide"), passedOn, passedOn, passedOn), results) + } + + @Test + fun `only http(s) is ever matched in the text`() { + val other = screen( + cols = 40, + rows = 3, + lines = listOf(Line("ftp://example.com/x javascript:alert(1)"), Line("file:///etc/passwd mailto:a@b.example")) + ) + val results = taps(other, Tap(3, 0), Tap(25, 0), Tap(3, 1), Tap(25, 1)) + assertEquals(List(4) { passedOn }, results) + } + + @Test + fun `the link provider underlines the whole wrapped URL on every row and opens it`() { + val reply = run(repainted) { putJsonArray("provideLinks") { add(JsonPrimitive(1)); add(JsonPrimitive(3)); add(JsonPrimitive(4)) } } + val provided = reply["provideLinks"]!!.jsonArray.map { it.jsonObject["links"]!! } + for (row in 0..1) { + val link = provided[row].jsonArray.single().jsonObject + assertEquals(oauth, link["text"]!!.jsonPrimitive.content) + // 1-based and inclusive: from column 7 of the first row to column 12 of the third. + val range = link["range"]!!.jsonObject + assertEquals(listOf(7, 1, 12, 3), listOf("start", "end").flatMap { k -> listOf("x", "y").map { range[k]!!.jsonObject[it]!!.jsonPrimitive.int } }) + assertEquals(listOf(oauth), link["opened"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + assertEquals(JsonNull, provided[2], "a row with no URL has no links") + } + + @Test + fun `OSC 8 links go through options linkHandler, never confirm(), and only http(s) is opened`() { + val uris = listOf("https://a.example/x", "HTTP://B.Example/y", "javascript:alert(1)", "file:///etc/passwd", "intent://x#Intent;end", "not a url") + val reply = run(screen(20, 3, emptyList())) { putJsonArray("linkHandler") { uris.forEach { add(JsonPrimitive(it)) } } } + val opened = reply["linkHandler"]!!.jsonArray.map { r -> r.jsonObject["opened"]!!.jsonArray.map { it.jsonPrimitive.content } } + // The URL parser's own spelling crosses the bridge: scheme and host lower-cased. + assertEquals(listOf(listOf("https://a.example/x"), listOf("http://b.example/y"), emptyList(), emptyList(), emptyList(), emptyList()), opened) + assertEquals(0, reply["confirmCalls"]!!.jsonPrimitive.int) + } + + @Test + fun `everything that crosses the bridge is a link the app accepts`() { + val reply = run(repainted) { + putJsonArray("linkHandler") { add(JsonPrimitive("https://a.example/x?q=a b")) } + putJsonArray("taps") { add(buildJsonObject { put("col", 8); put("row", 0) }) } + } + val crossed = (reply["linkHandler"]!!.jsonArray + reply["taps"]!!.jsonArray) + .flatMap { r -> r.jsonObject["opened"]!!.jsonArray.map { it.jsonPrimitive.content } } + assertEquals(2, crossed.size) + for (url in crossed) assertNotNull(ExternalLink.parse(url), "ExternalLink refuses what terminal.js sends: $url") + } + + private fun copySheet(screen: JsonObject): Pair { + val reply = run(screen) { put("copySheet", true) } + val sheet = reply["copySheet"]!!.jsonObject + assertEquals(1, sheet["calls"]!!.jsonPrimitive.int) + val raw = sheet["raw"]!!.jsonPrimitive.content + return Json.parseToJsonElement(raw).jsonObject to raw + } + + @Test + fun `the Copy sheet gets the lines, soft wraps joined and repainted rows kept apart, and the links`() { + val buffer = screen( + cols = 20, + rows = 8, + lines = listOf( + Line("Open: https://exampl"), + Line("e.com/oauth/authoriz"), + Line("e?client=abc"), + Line(""), + Line("a long line that xte"), + Line("rm wrapped itself", wrapped = true), + // The same URI under two link ids (xterm keys OSC 8 links by id): listed once. + Line("docs", links = listOf(Triple(0, 3, "https://docs.example.com/"), Triple(0, 0, "https://docs.example.com/"))), + Line("evil", links = listOf(Triple(0, 3, "javascript:alert(1)"))), + ) + ) + val (json, raw) = copySheet(buffer) + assertEquals( + listOf( + "Open: https://exampl", + "e.com/oauth/authoriz", + "e?client=abc", + "", + "a long line that xterm wrapped itself", + "docs", + "evil" + ), + json["lines"]!!.jsonArray.map { it.jsonPrimitive.content } + ) + assertEquals(listOf(oauth, "https://docs.example.com/"), json["links"]!!.jsonArray.map { it.jsonPrimitive.content }) + // The app reads what the page sends. + val parsed = assertNotNull(TerminalCopy.parse(raw)) + assertEquals(listOf(oauth, "https://docs.example.com/"), parsed.links.map { it.url }) + assertEquals(7, parsed.lines.size) + } + + @Test + fun `the Copy sheet drops the blank rows under the text and opens at the visible screen`() { + // 30 rows of scrollback above a 5-row screen (a buffer with scrollback: no tmux). + val lines = (0 until 33).map { Line("line $it") } + val (json, raw) = copySheet(screen(cols = 20, rows = 5, lines = lines + Line("") + Line(""), viewportY = 30)) + assertEquals((0 until 33).map { "line $it" }, json["lines"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertEquals(30, json["firstVisible"]!!.jsonPrimitive.int) + assertEquals(30, assertNotNull(TerminalCopy.parse(raw)).firstVisible) + } + + @Test + fun `the Copy sheet takes at most the last 500 rows, without a line cut off at the top`() { + val lines = (0 until 600).map { i -> if (i == 100) Line("cont", wrapped = true) else Line("r$i") } + val (json, _) = copySheet(screen(cols = 10, rows = 5, lines = lines)) + val got = json["lines"]!!.jsonArray.map { it.jsonPrimitive.content } + // Row 100 continues row 99, which is outside the 500: it is not offered as a line of its own. + assertEquals((101 until 600).map { "r$it" }, got) + } + + @Test + fun `an empty screen gives an empty sheet`() { + val (json, raw) = copySheet(screen(cols = 20, rows = 4, lines = emptyList())) + assertTrue(json["lines"]!!.jsonArray.isEmpty()) + assertTrue(json["links"]!!.jsonArray.isEmpty()) + assertEquals(TerminalCopy.Snapshot(emptyList(), emptyList(), 0), TerminalCopy.parse(raw)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsLiveTouchTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsLiveTouchTest.kt new file mode 100644 index 0000000000..1a62ad73d5 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsLiveTouchTest.kt @@ -0,0 +1,87 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.* +import kotlin.test.* + +/** Runs the shipped page, with a child DOM event that can actually bubble to the terminal host. */ +class TerminalJsLiveTouchTest { + private fun page(text: String) = buildJsonObject { + put("status", "history"); put("viewId", "b3b8e879-6449-4c8c-8529-4c02d6885878") + put("offset", 4); put("totalRows", 49); put("cols", 52) + put("olderTruncated", false); put("hasOlder", true); put("hasNewer", true) + putJsonArray("rows") { add(buildJsonObject { + put("text", text); put("isWrapped", false); put("section", "normal") + }) } + } + private fun checkpoint(label: String) = buildJsonObject { put("checkpoint", label) } + private fun frames(count: Int) = buildJsonObject { put("frames", count) } + private fun liveTouch(type: String, y: Int = 20) = buildJsonObject { + put("liveTouch", buildJsonObject { put("type", type); put("x", 40); put("y", y) }) + } + private fun currentPage(text: String) = buildJsonObject { put("history", page(text)); put("epoch", 0) } + private fun run(vararg after: JsonObject) = TerminalJsDriver.run(buildJsonObject { + put("copyLimit", 8) + put("screen", buildJsonObject { put("cols", 52); put("rows", 45); put("cellHeight", 20) }) + putJsonArray("historyResponses") { add(page("before")) } + putJsonArray("taps") { add(buildJsonObject { + put("col", 2); put("row", 3); put("startTime", 10); put("endTime", 95) + put("moves", Json.parseToJsonElement("[[0,100,50],[0,200,90]]")) + putJsonArray("after") { + add(frames(2)); add(checkpoint("before-live")); after.forEach { add(it) } + } + }) } + }) + private fun tap(result: JsonObject) = result["taps"]!!.jsonArray.single().jsonObject + private fun at(result: JsonObject, label: String) = tap(result)["checkpoints"]!!.jsonArray + .map { it.jsonObject }.single { it["label"]!!.jsonPrimitive.content == label } + private fun int(point: JsonObject, field: String) = point[field]!!.jsonPrimitive.int + private fun assertStopped(result: JsonObject, label: String) { + val before = at(result, "before-live") + val held = at(result, label) + assertEquals(13, int(before, "notches"), "the control starts with an actual two-frame coast") + assertEquals(int(before, "requests"), int(held, "requests"), "held Live touch must stop before click") + assertEquals(int(before, "notches"), int(held, "notches"), "no old coast or parent gesture can send more movement") + assertTrue(int(held, "stops") > int(before, "stops"), "touch-down must stop native pending movement") + assertEquals(0, int(held, "framesQueued")) + assertEquals(0, int(held, "historyCloses"), "a hold is not a Live click") + assertTrue(held["historyVisible"]!!.jsonPrimitive.boolean, "touch-down preserves the displayed page") + } + + @Test fun `held Live touch immediately stops a coast without a parent gesture or closing history`() { + val result = run(liveTouch("touchstart"), checkpoint("touch-down"), frames(32), + liveTouch("touchmove", 120), frames(4), checkpoint("held"), + currentPage("same epoch remains current"), checkpoint("current-page")) + assertStopped(result, "touch-down") + assertStopped(result, "held") + assertStopped(result, "current-page") + val event = tap(result)["domEvents"]!!.jsonArray.first().jsonObject + assertTrue(event["propagationStopped"]!!.jsonPrimitive.boolean) + assertEquals(2, int(at(result, "touch-down"), "stops"), "the child cancels once without starting the parent gesture") + assertEquals(listOf("same epoch remains current"), result["history"]!!.jsonObject["rows"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertEquals(0, result["historyCloses"]!!.jsonPrimitive.int) + } + + @Test fun `ordinary Live click closes once after stopped touch and rejects an old delivered page`() { + val result = run(liveTouch("touchstart"), checkpoint("touch-down"), frames(32), + liveTouch("touchend"), buildJsonObject { put("live", true) }, checkpoint("clicked"), + currentPage("obsolete page"), frames(8), checkpoint("late-page")) + assertStopped(result, "touch-down") + for (label in listOf("clicked", "late-page")) { + val point = at(result, label) + assertEquals(13, int(point, "notches")) + assertEquals(0, int(point, "framesQueued")) + assertFalse(point["historyVisible"]!!.jsonPrimitive.boolean) + assertEquals(1, int(point, "historyCloses")) + } + assertTrue(result["history"]!!.jsonObject["hidden"]!!.jsonPrimitive.boolean) + } + + @Test fun `a cancelled Live press stops movement and keeps history current without a click`() { + val result = run(liveTouch("touchstart"), frames(32), liveTouch("touchcancel"), + currentPage("cancelled press keeps the epoch"), frames(8), checkpoint("cancelled")) + assertStopped(result, "cancelled") + assertEquals(listOf("cancelled press keeps the epoch"), result["history"]!!.jsonObject["rows"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertEquals(0, result["historyCloses"]!!.jsonPrimitive.int) + assertEquals(0, result["liveResets"]!!.jsonPrimitive.int) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt new file mode 100644 index 0000000000..90baf5079f --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsOsc52Test.kt @@ -0,0 +1,96 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.Osc52 +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A53: the app's real terminal.js, run in node against stub xterm/bridge objects ([TerminalJsDriver]). + * It must apply the size cap it reads from the bridge BEFORE a copy crosses it, and otherwise hand + * over the WHOLE sequence so [Osc52.parse] can apply the desktop's rules (the `;` separator, the `?` + * refusal). Skips without node. + */ +class TerminalJsOsc52Test { + private data class Call(val name: String, val length: Int? = null, val sameAsInput: Boolean? = null) + private data class Outcome(val returned: Boolean, val calls: List) + + private fun run(copyLimit: Int, sequences: List): Pair> { + val reply = TerminalJsDriver.run( + buildJsonObject { + put("copyLimit", copyLimit) + putJsonArray("osc52") { sequences.forEach { add(JsonPrimitive(it)) } } + } + ) + val outcomes = reply["osc52"]!!.jsonArray.map { r -> + val o = r.jsonObject + Outcome( + o["returned"]!!.jsonPrimitive.boolean, + o["calls"]!!.jsonArray.map { c -> + val call = c.jsonArray + val arg = call.getOrNull(1) as? JsonObject + Call( + call[0].jsonPrimitive.content, + arg?.get("length")?.jsonPrimitive?.int, + arg?.get("sameAsInput")?.jsonPrimitive?.boolean + ) + } + ) + } + return reply["copyLimitCalls"]!!.jsonPrimitive.int to outcomes + } + + private fun copied(data: String) = Call("onCopy", data.length, true) + private val tooLarge = Call("onCopyTooLarge") + + @Test + fun `the cap comes from the bridge and is applied before anything crosses it`() { + val atLimit = "c;" + "A".repeat(8) + val overLimit = "c;" + "A".repeat(9) + val (limitCalls, results) = run(copyLimit = 8, listOf(atLimit, overLimit)) + assertTrue(limitCalls >= 1, "terminal.js must read the cap from the bridge, not keep its own") + assertEquals(Outcome(true, listOf(copied(atLimit))), results[0]) + assertEquals(Outcome(true, listOf(tooLarge)), results[1]) + } + + @Test + fun `the whole sequence crosses, so Kotlin sees the selection and the separator`() { + val normal = "c;SGVsbG8=" + val query = "c;?" + val noSeparator = "SGVsbG8=" + val (_, results) = run(copyLimit = Osc52.MAX_BASE64, listOf(normal, query, noSeparator)) + assertEquals(Outcome(true, listOf(copied(normal))), results[0]) + assertEquals(Outcome(true, listOf(copied(query))), results[1]) // Osc52.parse refuses it + assertEquals(Outcome(true, emptyList()), results[2]) // no ';': nothing to parse + } + + @Test + fun `a long selection field never crosses the bridge, whatever the payload`() { + val atMax = "c".repeat(Osc52.MAX_SELECTION) + ";SGVsbG8=" + val tooLong = "c".repeat(2_000_000) + ";SGVsbG8=" + val (_, results) = run(copyLimit = Osc52.MAX_BASE64, listOf(atMax, tooLong)) + assertEquals(Outcome(true, listOf(copied(atMax))), results[0]) + assertEquals(Outcome(true, emptyList()), results[1]) + } + + @Test + fun `the payload tmux forwards under the desktop's cap never reaches onCopy`() { + // 986,675 base64 characters: what the audit's verifier measured tmux 3.4 forwarding. + val huge = "c;" + "A".repeat(986_675) + val atCap = "c;" + "A".repeat(Osc52.MAX_BASE64) + val (_, results) = run(copyLimit = Osc52.MAX_BASE64, listOf(huge, atCap)) + assertEquals(Outcome(true, listOf(tooLarge)), results[0]) + assertEquals(Outcome(true, listOf(copied(atCap))), results[1]) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsScrollTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsScrollTest.kt new file mode 100644 index 0000000000..0f82893204 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsScrollTest.kt @@ -0,0 +1,195 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** Runs the app's real swipe handlers; tmux scrolling itself is exercised by SshTransportTest. */ +class TerminalJsScrollTest { + private fun gesture(vararg moves: Pair, configure: JsonObjectBuilder.() -> Unit = {}) = buildJsonObject { + put("col", 2) + put("row", 3) + putJsonArray("moves") { + for ((dx, dy) in moves) add(buildJsonArray { add(JsonPrimitive(dx)); add(JsonPrimitive(dy)) }) + } + configure() + } + + private fun run(vararg gestures: JsonObject, cellHeight: Int = 20, fontSize: Int = 13, originUnavailable: Boolean = false): List = + TerminalJsDriver.run(buildJsonObject { + put("copyLimit", 8) + put("fontSize", fontSize) + put("originUnavailable", originUnavailable) + put("screen", buildJsonObject { + put("cols", 52) + put("rows", 45) + put("cellHeight", cellHeight) + }) + putJsonArray("taps") { gestures.forEach { add(it) } } + })["taps"]!!.jsonArray.map { it.jsonObject } + + private fun scrolls(gesture: JsonObject) = gesture["scrolls"]!!.jsonArray.map { + it.jsonArray[0].jsonPrimitive.boolean to it.jsonArray[1].jsonPrimitive.int + } + + private fun ntAction(fn: String, vararg args: String) = buildJsonObject { + put("nt", fn) + putJsonArray("args") { args.forEach { add(JsonPrimitive(it)) } } + } + + @Test + fun `a short swipe requests one wheel notch per rendered row of finger movement`() { + val gestures = run(gesture(0 to 200), gesture(0 to -200), gesture(200 to 0), + gesture(0 to 19), gesture(0 to 200) { put("fingers", 2) }) + assertEquals(listOf(true to 10), scrolls(gestures[0]), "downward drag reveals earlier output") + assertEquals(listOf(false to 10), scrolls(gestures[1]), "upward drag moves toward live output") + assertTrue(gestures.take(2).all { it["movePrevented"]!!.jsonPrimitive.boolean }, + "the gesture belongs to tmux rather than the browser viewport") + for (gesture in gestures.drop(2)) assertTrue(scrolls(gesture).isEmpty(), + "horizontal, sub-notch and multi-touch gestures do not request history") + } + + @Test + fun `swipe gain uses the rendered row height with a font fallback before layout`() { + assertEquals(listOf(true to 5), scrolls(run(gesture(0 to 200), cellHeight = 40).single())) + assertEquals(listOf(true to 10), scrolls(run(gesture(0 to 280), cellHeight = 0, fontSize = 20).single())) + } + + @Test + fun `terminal replies focus and both mouse encodings preserve queued history`() { + val reports = listOf("\u001b[I", "\u001b[O", "\u001b[12;3R", "\u001b[?1;2c") + val mouse = "\u001b[<35;4;12M" + val legacy = "\u001b[M !!" + val result = run(gesture(0 to 900) { + putJsonArray("after") { + add(buildJsonObject { put("mouse", mouse) }) // xterm marks SGR mouse as user input + reports.forEach { add(buildJsonObject { put("report", it) }) } + add(buildJsonObject { put("binary", legacy) }) + } + }).single() + assertEquals(listOf(true to 20, true to 20, true to 5), scrolls(result)) + assertTrue(result["inputs"]!!.jsonArray.isEmpty(), "reports must not enter the native input barrier") + assertEquals(listOf(mouse) + reports + legacy, + result["reports"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + + @Test + fun `missing internal input origin has a guarded public fallback`() { + val reports = listOf("\u001b[I", "\u001b[O", "\u001b[12;3R", "\u001b[?1;2c") + val result = run(gesture(0 to 200) { + putJsonArray("after") { reports.forEach { add(buildJsonObject { put("report", it) }) } } + }, originUnavailable = true).single() + assertEquals(listOf(true to 10), scrolls(result)) + assertEquals(reports, result["reports"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertTrue(result["inputs"]!!.jsonArray.isEmpty()) + val typed = run(gesture(0 to 200) { + putJsonArray("after") { add(buildJsonObject { put("data", "x") }) } + }, originUnavailable = true).single() + assertTrue(scrolls(typed).isEmpty()) + assertEquals(listOf("x"), typed["inputs"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + + @Test + fun `touch moves in one frame merge into one ordered request`() { + val result = run(gesture(0 to 10, 0 to 50, 0 to 100, 0 to 200, 0 to 400)).single() + assertEquals(listOf(true to 20), scrolls(result)) + assertTrue(result["scrollsBeforeFrame"]!!.jsonArray.isEmpty(), "touchmove sends nothing immediately") + assertEquals(listOf(1), result["scrollFrames"]!!.jsonArray.map { it.jsonObject["frame"]!!.jsonPrimitive.int }) + } + + @Test + fun `direction reversals during a gesture keep their order across frames`() { + val result = run(gesture(0 to 200, 0 to -100)).single() + assertEquals(listOf(true to 10, false to 15), scrolls(result)) + assertEquals(listOf(1, 2), result["scrollFrames"]!!.jsonArray.map { it.jsonObject["frame"]!!.jsonPrimitive.int }) + } + + @Test + fun `a fast swipe retains its distance past the transport cap and drains after touchend`() { + val result = run(gesture(0 to 900)).single() + assertEquals(listOf(true to 20, true to 20, true to 5), scrolls(result)) + assertTrue(result["scrollsBeforeFrame"]!!.jsonArray.isEmpty()) + assertEquals(listOf(1, 2, 3), result["scrollFrames"]!!.jsonArray.map { it.jsonObject["frame"]!!.jsonPrimitive.int }) + } + + @Test + fun `reset new input and a hidden page discard an outstanding swipe`() { + val actions = listOf(ntAction("reset"), ntAction("paint", "eA=="), ntAction("cancelScroll"), + ntAction("key", "esc"), ntAction("raw", "Aw=="), ntAction("submit", "eA=="), + buildJsonObject { put("data", "x") }) + + listOf("blur", "pagehide", "hidden", "touchcancel", "multitouch") + .map { buildJsonObject { put("event", it) } } + for (action in actions) { + val result = run(gesture(0 to 900) { putJsonArray("after") { add(action) } }).single() + assertTrue(scrolls(result).isEmpty(), "no stale scroll after $action") + if (action == ntAction("raw", "Aw==")) { + assertEquals(listOf("\u0003"), result["inputs"]!!.jsonArray.map { it.jsonPrimitive.content }, + "the raw chip still sends its exact control byte") + } + } + } + + @Test + fun `cancellation after the first clamped frame drops the remainder`() { + val cancels = listOf(ntAction("raw", "Gw=="), + buildJsonObject { put("event", "touchcancel") }, buildJsonObject { put("event", "multitouch") }) + for (cancel in cancels) { + val result = run(gesture(0 to 900) { + putJsonArray("after") { + add(buildJsonObject { put("frame", true) }) + add(cancel) + } + }).single() + assertEquals(listOf(true to 20), scrolls(result), "nothing queued after $cancel") + if (cancel == ntAction("raw", "Gw==")) { + assertEquals(listOf("\u001b"), result["inputs"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + } + } + + @Test + fun `the delayed submit Enter cancels a swipe begun after the paste`() { + val result = run(gesture(0 to 900) { + putJsonArray("before") { + add(buildJsonObject { + put("nt", "submit") + put("args", buildJsonArray { add(JsonPrimitive("eA==")); add(JsonPrimitive(true)) }) + }) + } + putJsonArray("after") { add(buildJsonObject { put("timers", true) }) } + }).single() + assertTrue(scrolls(result).isEmpty(), "the delayed Enter is an input barrier too") + assertEquals(listOf("x", "\r"), result["inputs"]!!.jsonArray.map { it.jsonPrimitive.content }) + } + + @Test + fun `suspending scroll cancels pending work and rejects gestures until attach resumes it`() { + val results = run( + gesture(0 to 900) { putJsonArray("after") { add(ntAction("suspendScroll")) } }, + gesture(0 to 200), + gesture(0 to 200) { putJsonArray("before") { add(ntAction("resumeScroll")) } }, + ) + assertTrue(scrolls(results[0]).isEmpty()) + assertTrue(scrolls(results[1]).isEmpty()) + assertEquals(listOf(true to 10), scrolls(results[2])) + } + + @Test + fun `a frame delayed by a page suspension drops stale input and the next gesture works`() { + val results = run(gesture(0 to 200) { put("frameDelay", 251) }, gesture(0 to 200)) + assertTrue(scrolls(results[0]).isEmpty()) + assertEquals(listOf(true to 10), scrolls(results[1])) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsXtermInteractionTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsXtermInteractionTest.kt new file mode 100644 index 0000000000..81cd3b4d49 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalJsXtermInteractionTest.kt @@ -0,0 +1,115 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Real bundled xterm produces the events here; jsdom supplies browser facilities, not input events. */ +class TerminalJsXtermInteractionTest { + @Test + fun `real terminal reports preserve a swipe while keyboard paste and IME cancel it`() { + val root = InteropHarness.repoRoot + // Installed npm dependencies are represented by package-lock.json in the workflow filter. + val nodeOk = runCatching { + ProcessBuilder("node", "-e", "require.resolve('jsdom')").directory(root).start().waitFor() == 0 + }.getOrDefault(false) + assumeTrue(nodeOk, + "node + npm ci (jsdom) are needed to run the actual xterm bundles") + val driver = File(root, "android/protocol/src/test/interop/terminal-xterm-interaction-driver.cjs") + val asset = File(root, "android/app/src/main/assets/terminal/terminal.js") + val process = ProcessBuilder("node", driver.path, asset.path).directory(root).start() + val reply = try { + assertTrue(process.waitFor(30, TimeUnit.SECONDS), "actual-xterm interaction driver timed out") + val out = process.inputStream.bufferedReader().readText() + val err = process.errorStream.bufferedReader().readText() + assertEquals(0, process.exitValue(), "actual-xterm driver failed: $err") + Json.parseToJsonElement(out.trim()).jsonObject + } finally { + if (process.isAlive) process.destroyForcibly() + } + assertEquals(45, reply["rows"]!!.jsonPrimitive.int) + assertEquals(52, reply["cols"]!!.jsonPrimitive.int) + assertEquals("any", reply["mouseMode"]!!.jsonPrimitive.content) + val cases = reply["results"]!!.jsonArray.associate { item -> + item.jsonObject["name"]!!.jsonPrimitive.content to item.jsonObject + } + assertEquals(10, cases.size) + val expectedReports = mapOf( + "focus-report" to "\u001b[O", "mouse-motion-report" to "\u001b[<35;3;15M", + "cursor-query-reply" to "\u001b[1;1R", "device-attributes-reply" to "\u001b[?1;2c", + "legacy-mouse-report" to "\u001b[MC%1", + ) + for (name in listOf("touch-only") + expectedReports.keys) { + val case = cases.getValue(name) + assertEquals(20, case["notches"]!!.jsonPrimitive.int, "$name preserves both halves of the real swipe") + val events = case["events"]!!.jsonArray.map { it.jsonObject } + assertFalse(events.any { "input" in it }, "$name must not enter the native input barrier") + expectedReports[name]?.let { expected -> + assertTrue(events.any { it["report"]?.jsonPrimitive?.content == expected }, + "$name emits the actual encoded report to its own bridge path") + } + } + assertTrue(cases.getValue("mouse-motion-report")["events"]!!.jsonArray.any { + it.jsonObject["userOrigin"]?.jsonPrimitive?.boolean == true + }, "xterm marks SGR mouse as user input; that flag alone cannot identify typed input") + val expectedInput = mapOf( + "keyboard-escape" to "\u001b", "bracketed-paste" to "\u001b[200~pasted text\u001b[201~", + "ime-input" to "日", "ime-composition" to "本", + ) + for ((name, expected) in expectedInput) { + val case = cases.getValue(name) + assertEquals(0, case["notches"]!!.jsonPrimitive.int, "$name stops pending movement and the gesture") + val inputs = case["events"]!!.jsonArray.mapNotNull { it.jsonObject["input"]?.jsonPrimitive?.content } + assertEquals(listOf(expected), inputs, "$name keeps its exact bytes on the input path") + } + val kinetic = reply["kinetic"]!!.jsonObject + assertTrue(kinetic["notches"]!!.jsonPrimitive.int > 10, "the actual bundle coasts after release") + assertTrue(kinetic["notches"]!!.jsonPrimitive.int <= 70) + assertTrue(kinetic["duration"]!!.jsonPrimitive.int <= 1016) + val kineticEvents = kinetic["events"]!!.jsonArray.map { it.jsonObject } + assertEquals(1, kineticEvents.count { "stop" in it }, "focus/query replies preserve the real coast") + assertTrue(kineticEvents.any { it["report"]?.jsonPrimitive?.content == "\u001b[O" }) + assertTrue(kineticEvents.any { it["report"]?.jsonPrimitive?.content == "\u001b[1;1R" }) + val repaint = reply["repaint"]!!.jsonObject + assertEquals("none", repaint["touchAction"]!!.jsonPrimitive.content, + "the computed stable-screen CSS reserves the gesture for the terminal") + val removed = repaint["removedSpan"]!!.jsonObject + assertTrue(removed["originalSpanRemoved"]!!.jsonPrimitive.boolean, "xterm's real redraw removes the touched text span") + assertFalse(removed["targetStillConnected"]!!.jsonPrimitive.boolean) + assertEquals(listOf("touchstart", "touchmove"), removed["hostEvents"]!!.jsonArray.map { it.jsonPrimitive.content }, + "later events on the old detached span no longer reach the page swipe handlers") + assertEquals(1, removed["notches"]!!.jsonPrimitive.int) + val stable = repaint["pageHitTarget"]!!.jsonObject + assertTrue(stable["originalSpanRemoved"]!!.jsonPrimitive.boolean) + assertTrue(stable["targetWasScreen"]!!.jsonPrimitive.boolean, "real page CSS targets the stable screen behind painted text") + assertTrue(stable["targetStillConnected"]!!.jsonPrimitive.boolean) + assertEquals(listOf("touchstart", "touchmove", "touchmove", "touchend"), + stable["hostEvents"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertTrue(stable["notches"]!!.jsonPrimitive.int > 5, "the rest of the drag and release coast survive a real repaint") + val history = reply["history"]!!.jsonObject + assertTrue(history["visible"]!!.jsonPrimitive.boolean) + assertTrue(history["rowReplaced"]!!.jsonPrimitive.boolean) + assertTrue(history["layerConnected"]!!.jsonPrimitive.boolean) + assertTrue(history["noHtml"]!!.jsonPrimitive.boolean) + assertEquals(0, history["resets"]!!.jsonPrimitive.int) + assertTrue(history["applicationCursor"]!!.jsonPrimitive.boolean, "Live parsing continues behind the separate history layer") + assertFalse(history["bracketedPaste"]!!.jsonPrimitive.boolean) + assertTrue(history["liveText"]!!.jsonPrimitive.content.contains("live output behind")) + assertEquals(listOf("https://old.example/x"), history["openedUrls"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertEquals(listOf("界 https://old.example/x", "inert captured text"), + history["copySnapshot"]!!.jsonObject["lines"]!!.jsonArray.map { it.jsonPrimitive.content }) + assertTrue(history["events"]!!.jsonArray.any { "report" in it.jsonObject }, "The real live parser still answers cursor queries") + assertTrue(history["events"]!!.jsonArray.count { "scroll" in it.jsonObject } > 1, "Replacing history rows preserves the rest of the drag and coast") + assertTrue(history["closedAfterInput"]!!.jsonPrimitive.boolean, "An old queued page cannot reopen after actual paste input") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalKeyboardChipTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalKeyboardChipTest.kt new file mode 100644 index 0000000000..61d636e013 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalKeyboardChipTest.kt @@ -0,0 +1,64 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A46: the key row's ⌨ chip must bring up the soft keyboard for the terminal. It used to run only + * `term.focus()` in the page, which does nothing on the textarea that already has focus (Blink returns + * early), cannot ask Android for the keyboard, and leaves Android's focus (and so the keyboard) with + * the input bar's text field. + * + * The page half runs the app's real terminal.js ([TerminalJsDriver]) against a textarea stub that + * follows Blink's rule. The Android half (Compose focus, the WebView's focus, InputMethodManager) + * cannot run on a JVM, so its order is pinned in the app's source instead; what it does on a device + * has not been checked. + */ +class TerminalKeyboardChipTest { + private data class Focus(val changes: List, val focusedAfter: Boolean) + + private fun focusForKeyboard(textareaFocused: Boolean): Focus { + val reply = TerminalJsDriver.run( + buildJsonObject { + put("copyLimit", 8) + put("textareaFocused", textareaFocused) + putJsonArray("nt") { add(JsonPrimitive("focusForKeyboard")) } + } + ) + val r = reply["nt"]!!.jsonArray.single().jsonObject + return Focus(r["focusChanges"]!!.jsonArray.map { it.jsonPrimitive.content }, r["focusedAfter"]!!.jsonPrimitive.boolean) + } + + @Test + fun `the page's focus really moves onto the textarea when it already had focus`() { + // The normal state once the terminal has been tapped: a bare focus() would change nothing. + assertEquals(Focus(listOf("blur", "focus"), focusedAfter = true), focusForKeyboard(textareaFocused = true)) + } + + @Test + fun `the page's focus moves onto the textarea when it did not have focus`() { + assertEquals(Focus(listOf("focus"), focusedAfter = true), focusForKeyboard(textareaFocused = false)) + } + + @Test + fun `the chip releases Compose's focus before it asks for the keyboard`() { + val chip = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalScreen.kt"), "KeyChip(\"⌨\")") + AppSourcePins.assertInOrder(chip, "focusManager.clearFocus()", "controller.showKeyboard()") + } + + @Test + fun `the WebView takes focus, then the page, then the keyboard is asked for after the focus change`() { + val body = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "fun showKeyboard()") + AppSourcePins.assertInOrder(body, ".requestFocus()", "jsPage(\"nt.focusForKeyboard()\")", ".post {", "showSoftInput(", "InputMethodManager.SHOW_IMPLICIT") + // Only the deferred call: a synchronous request right after requestFocus races the focus change. + assertEquals(1, Regex("""showSoftInput\(""").findAll(body).count()) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalLinksWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalLinksWiringTest.kt new file mode 100644 index 0000000000..65343230e5 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalLinksWiringTest.kt @@ -0,0 +1,116 @@ +package dev.nodeterm.protocol + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * A32: the Android half of links and copying, which cannot run on a JVM, pinned in the app's source. + * The page's half is [TerminalJsLinksTest]; the rules both halves delegate to are [TerminalCopyTest]. + * What it does on a phone is the device checklist's. + */ +class TerminalLinksWiringTest { + private val controller = AppSourcePins.ui("TerminalController.kt") + private val screen = AppSourcePins.ui("TerminalScreen.kt") + private val assets = File(InteropHarness.repoRoot, "android/app/src/main/assets/terminal") + + @Test + fun `a link from the page is offered only once ExternalLink accepts it, never opened on the tap`() { + val bridge = AppSourcePins.blockAfter(controller, "fun openUrl(url: String)") + AppSourcePins.assertInOrder(bridge, "if (!page.isCurrent(gen)) return", "ExternalLink.parse(url) ?: return", "linkOffer = link") + assertFalse("startActivity" in bridge, "the bridge opened the link on the tap itself:\n$bridge") + assertEquals(1, Regex("""linkOffer = link\b""").findAll(controller).count(), "linkOffer is set from somewhere else") + } + + @Test + fun `opening is a browsable ACTION_VIEW behind a catch, from the user's Open only`() { + val open = AppSourcePins.blockAfter(controller, "fun openLink(ctx: Context, link: ExternalLink)") + AppSourcePins.assertInOrder(open, "Intent(Intent.ACTION_VIEW, Uri.parse(link.url))", ".addCategory(Intent.CATEGORY_BROWSABLE)", "start(ctx, view") + val start = AppSourcePins.blockAfter(controller, "private fun start(ctx: Context, intent: Intent, failed: String)") + AppSourcePins.assertInOrder(start, "try {", "ctx.startActivity(intent)", "catch (e: ActivityNotFoundException)", "catch (e: Exception)") + // The only ACTION_VIEW: the WebViewClient no longer opens what the page navigates to. + assertEquals(1, Regex("""Intent\.ACTION_VIEW""").findAll(controller).count()) + val client = AppSourcePins.blockAfter(controller, "fun createWebView(") + assertTrue("override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = true" in client, client) + // Both screen surfaces call it with the screen's own context. + assertEquals(2, Regex("""controller\.openLink\(ctx, link\)""").findAll(screen).count()) + } + + @Test + fun `copied and shared text keeps to the clipboard cap and a refused write is caught`() { + for (fn in listOf("fun copyLines(ctx: Context, selection: TerminalCopy.Selection)", "fun shareLines(ctx: Context, selection: TerminalCopy.Selection)")) { + val body = AppSourcePins.blockAfter(controller, fn) + AppSourcePins.assertInOrder(body, "TerminalCopy.text(lines, selection)", "is TerminalCopy.Text.Copy ->", "TerminalCopy.Text.TooLarge ->") + } + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(controller, "fun shareLines("), "Intent(Intent.ACTION_SEND)", "start(ctx, Intent.createChooser(send, null)") + val write = AppSourcePins.blockAfter(controller, "private fun writeClipboard(") + AppSourcePins.assertInOrder(write, "try {", "cm.setPrimaryClip(", "} catch (e: Exception) {") + } + + @Test + fun `the Copy chip opens the sheet, which a page not yet loaded is never asked for`() { + val chip = AppSourcePins.blockAfter(screen, "KeyChip(\"Copy\")") + AppSourcePins.assertInOrder(chip, "focusManager.clearFocus()", "controller.openCopySheet()") + val open = AppSourcePins.blockAfter(controller, "fun openCopySheet()") + AppSourcePins.assertInOrder(open, "!page.isReady", "return", "evaluateJavascript(\"nt.copySheet()\"") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(controller, "fun onCopySheet(json: String)"), "TerminalCopy.parse(json)", "copySheet = snapshot") + AppSourcePins.assertInOrder(screen, "controller.copySheet?.let { snapshot -> CopySheet(controller, snapshot) }") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(screen, "private fun CopySheet("), "BackHandler { controller.closeCopySheet() }") + } + + @Test + fun `a touch on a bar, card or sheet over the terminal stays there`() { + // Review of A32: Compose hit-tests overlapping siblings from the top down and moves on to the + // next where the top one has no pointer-input node, and background() and Text have none. A tap + // on the link bar's URL or the Copy sheet's title reached the WebView (a click or scroll in the + // pane, or another link offered) and one on the sheet's bottom row the input bar (the keyboard). + val overlays = screen.lines().filter { "Modifier.align(Alignment." in it } + // The five state cards, the resume offer, the notice and the bottom bars (the link offer's). + assertTrue(overlays.size >= 8, "found only ${overlays.size} overlays over the terminal:\n${overlays.joinToString("\n")}") + for (line in overlays) { + val blocks = line.indexOf(".blockTouchesBelow()") + assertTrue(blocks >= 0, "an overlay over the terminal lets a touch on its text through:\n$line") + val padding = line.indexOf(".padding(") + assertTrue(padding < 0 || blocks < padding, "the blocker sits inside the overlay's padding, which then lets touches through:\n$line") + } + assertTrue("LinkOffer(controller, link)" in AppSourcePins.blockAfter(screen, "Column(Modifier.align(Alignment.BottomCenter)")) + // The sheet covers the key row and the input bar too. + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(screen, "private fun CopySheet("), "Column(Modifier.fillMaxSize().blockTouchesBelow()") + // It only has to be a pointer-input node, and must consume nothing: a parent sees the Main pass + // after its children, whose taps and drags check the Final pass for a consumed change, so a + // consumed move (a finger's jitter) would cancel the overlay's own buttons and the sheet's scroll. + val overlaysKt = AppSourcePins.ui("Overlays.kt") + assertTrue("fun Modifier.blockTouchesBelow(): Modifier = pointerInput(Unit) {" in overlaysKt, overlaysKt) + val blocker = AppSourcePins.blockAfter(overlaysKt, "fun Modifier.blockTouchesBelow()") + assertTrue("awaitPointerEvent()" in blocker, blocker) + assertFalse("consume" in blocker, "the blocker consumes events, which cancels the overlay's own taps:\n$blocker") + } + + @Test + fun `the link bar can show the whole URL, which two lines cut short on a phone`() { + // Review of A32: two lines are a few dozen characters on a phone, too few to tell a long URL + // from one row's fragment of it; the bar now shows all of it on request, scrolling in the bar. + val offer = AppSourcePins.blockAfter(screen, "private fun LinkOffer(") + AppSourcePins.assertInOrder( + offer, + "link.url", + ".heightIn(max = ", + ".verticalScroll(", + "maxLines = if (whole) Int.MAX_VALUE else 2", + "TextButton(onClick = { whole = !whole })" + ) + assertTrue("var whole by remember(link)" in offer, "a new link must start collapsed:\n$offer") + } + + @Test + fun `the page loads no web-links addon, which cannot join repainted rows`() { + val html = File(assets, "index.html").readText() + val js = File(assets, "terminal.js").readText() + assertFalse(Regex("""web-?links""", RegexOption.IGNORE_CASE).containsMatchIn(html), html) + assertFalse("WebLinksAddon" in js) + assertFalse(File(assets, "addon-web-links.js").exists()) + assertTrue("term.registerLinkProvider(" in js && "term.options.linkHandler = {" in js) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalOutputTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalOutputTest.kt new file mode 100644 index 0000000000..5597fcfb3d --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalOutputTest.kt @@ -0,0 +1,207 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.TerminalOutput +import dev.nodeterm.protocol.host.TerminalPage +import dev.nodeterm.protocol.host.ViewerSlot +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Actual queue/page behavior with an explicit main-thread scheduler; no Android runtime claim. */ +class TerminalOutputTest { + private class Scheduled(val delay: Long, val run: () -> Unit) + private class Screen(ready: Boolean = true) { + val slot = ViewerSlot() + val page = TerminalPage() + val generation = page.build() + val callbacks = ArrayList() + val rendered = ArrayList() + val paints = ArrayList() + val chunks = ArrayList() + val output = TerminalOutput(slot::isCurrent, { delay, run -> callbacks += Scheduled(delay, run) }, + { ticket, text -> paints += text; offer(ticket, "paint:$text") }, + { ticket, bytes -> chunks += bytes; offer(ticket, "write:${bytes.decodeToString()}") }) + + init { if (ready) page.ready(generation) } + fun offer(ticket: Long, code: String) { if (page.offerViewer(ticket, code)) rendered += code } + fun begin(): Long = slot.begin().also { page.viewerChanged(it); output.begin(it) } + fun retire() { output.retire(); page.viewerChanged(null) } + fun ready() { rendered += page.ready(generation).orEmpty() } + } + + @Test fun `old delayed flush after a new paint neither writes old bytes nor drains the new viewer`() { + val s = Screen(); val old = s.begin() + assertTrue(s.output.append(old, "old".toByteArray())) + val oldFlush = s.callbacks.single() + val next = s.begin() + assertTrue(s.output.paint(next, "new snapshot")) + assertTrue(s.output.append(next, "new output".toByteArray())) + val paint = s.callbacks[1]; val newFlush = s.callbacks[2] + paint.run(); oldFlush.run() + assertEquals(listOf("paint:new snapshot"), s.rendered, "old callback cannot take newer bytes") + assertTrue(s.chunks.isEmpty(), "the retired queue must not emit even when the page also rejects it") + newFlush.run() + assertEquals(listOf("paint:new snapshot", "write:new output"), s.rendered) + } + + @Test fun `a producer paused after its earlier viewer check cannot append after retirement`() { + val s = Screen(); val old = s.begin() + val checked = CountDownLatch(1); val release = CountDownLatch(1) + var accepted = true + val producer = thread { + assertTrue(s.slot.isCurrent(old)) + checked.countDown() + check(release.await(2, TimeUnit.SECONDS)) + accepted = s.output.append(old, "stale".toByteArray()) + } + try { + assertTrue(checked.await(2, TimeUnit.SECONDS)) + s.retire(); s.slot.leave() + val next = s.begin() + assertTrue(s.output.append(next, "current".toByteArray())) + release.countDown(); producer.join(2_000) + assertFalse(producer.isAlive) + assertFalse(accepted, "admission rechecks the owner inside the queue, not the producer's earlier check") + s.callbacks.single().run() + assertEquals(listOf("write:current"), s.rendered) + } finally { release.countDown(); producer.join(2_000) } + } + + @Test fun `repeated flush callback cannot consume another batch of the same viewer`() { + val s = Screen(); val owner = s.begin() + s.output.append(owner, "one".toByteArray()) + val first = s.callbacks.single(); first.run() + s.output.append(owner, "two".toByteArray()) + val second = s.callbacks.last() + first.run() + assertEquals(listOf("write:one"), s.rendered, "each callback owns only its original batch") + second.run(); second.run() + assertEquals(listOf("write:one", "write:two"), s.rendered) + assertTrue(s.callbacks.all { it.delay == 16L }) + } + + @Test fun `retirement serializes with the admission check instead of clearing before a stale append`() { + val checking = CountDownLatch(1); val release = CountDownLatch(1) + val retiring = CountDownLatch(1); val retired = CountDownLatch(1) + val callbacks = ArrayList<() -> Unit>(); val emitted = ArrayList() + val output = TerminalOutput({ checking.countDown(); check(release.await(2, TimeUnit.SECONDS)); true }, + { _, callback -> callbacks += callback }, { _, _ -> error("no paint") }, { _, bytes -> emitted += bytes }) + output.begin(1) + val producer = thread { assertTrue(output.append(1, "old".toByteArray())) } + assertTrue(checking.await(2, TimeUnit.SECONDS)) + val retire = thread { retiring.countDown(); output.retire(); retired.countDown() } + try { + assertTrue(retiring.await(2, TimeUnit.SECONDS)) + assertFalse(retired.await(100, TimeUnit.MILLISECONDS), "owner check and append must share retirement's lock") + release.countDown(); producer.join(2_000); retire.join(2_000) + assertFalse(producer.isAlive); assertFalse(retire.isAlive) + callbacks.forEach { it() } + assertTrue(emitted.isEmpty(), "retirement must clear the admitted old batch before its callback") + } finally { release.countDown(); producer.join(2_000); retire.join(2_000) } + } + + @Test fun `snapshot drops earlier bytes and its early flush waits for paint before keeping later bytes`() { + val s = Screen(); val owner = s.begin() + s.output.append(owner, "before snapshot".toByteArray()) + val oldFlush = s.callbacks.single() + s.output.paint(owner, "snapshot") + val paint = s.callbacks.last() + s.output.append(owner, "after snapshot".toByteArray()) + val earlyFlush = s.callbacks.last() + oldFlush.run(); earlyFlush.run() + assertTrue(s.rendered.isEmpty(), "no live output may precede the initial snapshot") + paint.run() + assertEquals(listOf("paint:snapshot"), s.rendered) + s.callbacks.last().run(); earlyFlush.run(); paint.run() + assertEquals(listOf("paint:snapshot", "write:after snapshot"), s.rendered) + assertEquals(0L, paint.delay) + assertEquals(16L, s.callbacks.last().delay) + } + + @Test fun `binary batching preserves byte order and split UTF8 across the 192KiB boundary`() { + val s = Screen(); val owner = s.begin() + val bytes = "x".repeat(TerminalOutput.CHUNK_BYTES - 1).toByteArray() + "😀Ω\u0000\u001b".toByteArray() + val split = TerminalOutput.CHUNK_BYTES + 1 + s.output.append(owner, bytes.copyOfRange(0, split)) + s.output.append(owner, bytes.copyOfRange(split, bytes.size)) + assertEquals(1, s.callbacks.size, "packets share one 16ms batch") + s.callbacks.single().run() + assertEquals(listOf(192 * 1024, bytes.size - 192 * 1024), s.chunks.map { it.size }) + assertContentEquals(bytes, s.chunks.fold(byteArrayOf()) { all, chunk -> all + chunk }) + } + + @Test fun `background renderer loss supersession end and disposal all invalidate delayed callbacks`() { + for (reason in listOf("background", "renderer", "superseded", "ended", "disposed")) { + val s = Screen(); val old = s.begin() + s.output.paint(old, "old snapshot"); s.output.append(old, "old output".toByteArray()) + val oldCallbacks = s.callbacks.toList() + when (reason) { + "superseded" -> s.begin() + "ended" -> { assertTrue(s.slot.ended(old)); s.retire() } + "renderer" -> { s.retire(); s.page.lost(); s.slot.leave() } + "disposed" -> { s.retire(); s.slot.close() } + else -> { s.retire(); s.slot.leave() } + } + assertFalse(s.output.append(old, "late".toByteArray()), reason) + assertFalse(s.output.paint(old, "late paint"), reason) + oldCallbacks.forEach { it.run() } + assertTrue(s.rendered.isEmpty(), reason) + assertTrue(s.paints.isEmpty(), "$reason cannot emit a retired snapshot") + assertTrue(s.chunks.isEmpty(), "$reason cannot emit retired bytes") + } + } + + @Test fun `ordered current exit keeps its final paint and output once before retiring`() { + val s = Screen(); val owner = s.begin() + s.output.paint(owner, "snapshot"); s.output.append(owner, "final tail".toByteArray()) + assertTrue(s.output.finish(owner)) + assertEquals(listOf("paint:snapshot", "write:final tail"), s.rendered) + assertTrue(s.slot.ended(owner)); s.retire() + s.callbacks.forEach { it.run() } + assertFalse(s.output.finish(owner)) + assertEquals(listOf("paint:snapshot", "write:final tail"), s.rendered) + } + + @Test fun `same unready page reconnect drops old viewer paint output and input but keeps font setup`() { + val s = Screen(ready = false); val old = s.begin() + assertFalse(s.page.offer("font:20")) + s.output.paint(old, "old snapshot"); s.output.append(old, "old output".toByteArray()) + s.callbacks.toList().forEach { it.run() } + for (code in listOf("nt.raw(old)", "nt.key(enter)", "nt.resumeScroll()", "nt.showScrollView(old)")) s.offer(old, code) + val next = s.begin() + s.output.paint(next, "new snapshot"); s.output.append(next, "new output".toByteArray()) + s.callbacks.drop(2).toList().forEach { it.run() } + assertFalse(s.page.offerViewer(old, "nt.raw(late)")) + s.ready() + assertEquals(listOf("font:20", "paint:new snapshot", "write:new output"), s.rendered) + assertFalse(s.page.offerViewer(old, "nt.key(late)"), "a ready page also refuses foreign viewers") + } + + @Test fun `page loss rejects old output before any replacement viewer has begun`() { + val s = Screen(ready = false); val old = s.begin() + s.output.append(old, "old".toByteArray()) + s.retire(); s.page.lost(); s.slot.leave() + s.callbacks.single().run() + assertFalse(s.output.append(old, "late".toByteArray())) + val generation = s.page.build() + assertEquals(emptyList(), s.page.ready(generation)) + assertTrue(s.rendered.isEmpty()) + } + + @Test fun `superseded snapshot callback cannot replace the latest snapshot or consume its bytes`() { + val s = Screen(); val owner = s.begin() + s.output.paint(owner, "first"); val first = s.callbacks.single() + s.output.append(owner, "included in second snapshot".toByteArray()) + s.output.paint(owner, "second"); val second = s.callbacks.last() + s.output.append(owner, "after second".toByteArray()); val flush = s.callbacks.last() + first.run(); s.callbacks[1].run() + assertTrue(s.rendered.isEmpty()) + second.run(); flush.run() + assertEquals(listOf("paint:second", "write:after second"), s.rendered) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalOutputWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalOutputWiringTest.kt new file mode 100644 index 0000000000..0a43c394c7 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalOutputWiringTest.kt @@ -0,0 +1,52 @@ +package dev.nodeterm.protocol + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** App source pins only; queue/page behavior is exercised by TerminalOutputTest. */ +class TerminalOutputWiringTest { + @Test fun `app sinks delegate paint and bytes to the viewer-owned queue`() { + val source = AppSourcePins.ui("TerminalController.kt") + assertTrue(source.contains("isCurrent = slot::isCurrent")) + assertTrue(source.contains("main.postDelayed({ callback() }, delay)")) + val sink = AppSourcePins.blockAfter(source, "private fun sinkFor(") + assertTrue(sink.contains("output.paint(ticket, text)")) + assertTrue(sink.contains("output.append(ticket, bytes)")) + assertFalse(source.contains("outBuf"), "no second unowned output buffer") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(sink, "override fun onExit("), + "slot.isCurrent(ticket)", "output.finish(ticket)", "slot.ended(ticket)", "retireOutput()") + } + + @Test fun `all viewer retirement paths invalidate output and queued page commands`() { + val source = AppSourcePins.ui("TerminalController.kt") + val retire = AppSourcePins.blockAfter(source, "private fun retireOutput()") + AppSourcePins.assertInOrder(retire, "output.retire()", "page.viewerChanged(null)") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(source, "private fun attach()"), + "val ticket = slot.begin()", "page.viewerChanged(ticket)", "output.begin(ticket)", "val sink = sinkFor(ticket)") + for (method in listOf("fun onStop()", "fun dispose()", "private fun rendererGone(")) { + val block = AppSourcePins.blockAfter(source, method) + assertTrue(block.contains("retireOutput()"), "$method must retire output") + AppSourcePins.assertInOrder(block, "retireOutput()", if (method.contains("dispose")) "slot.close()" else "slot.leave()") + } + } + + @Test fun `queued raw keys resume and history capture a viewer while font setup stays page-global`() { + val source = AppSourcePins.ui("TerminalController.kt") + val owned = AppSourcePins.blockAfter(source, "private fun js(code:") + AppSourcePins.assertInOrder(owned, "val ticket = output.owner ?: return", "jsViewer(ticket, code)") + assertFalse(owned.contains("jsPage("), "an absent viewer never adopts stream commands as page-global") + assertTrue(AppSourcePins.blockAfter(source, "private fun jsViewer(").contains("page.offerViewer(ticket, code)")) + assertTrue(AppSourcePins.blockAfter(source, "private fun jsPage(").contains("page.offer(code)")) + assertTrue(AppSourcePins.blockAfter(source, "fun raw(data:").contains("js(\"nt.raw(")) + assertTrue(AppSourcePins.blockAfter(source, "fun key(name:").contains("js(\"nt.key(")) + assertTrue(source.contains("js(\"nt.resumeScroll()\")")) + assertTrue(source.contains("js(\"nt.showScrollView(")) + assertTrue(source.contains("jsPage(\"nt.setFontSize(")) + assertFalse(source.contains("js(\"nt.setFontSize(")) + assertTrue(source.contains("jsPage(\"nt.suspendScroll()\")")) + assertFalse(source.contains("js(\"nt.suspendScroll()\")")) + assertTrue(source.contains("jsPage(\"nt.focusForKeyboard()\")")) + assertTrue(AppSourcePins.blockAfter(source, "private fun retireActions()").contains("jsPage(\"nt.closeScrollView()\")")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt new file mode 100644 index 0000000000..ac1cc4a947 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalPageTest.kt @@ -0,0 +1,246 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.RendererRecovery +import dev.nodeterm.protocol.host.RendererRecovery.Outcome +import dev.nodeterm.protocol.host.RendererRecovery.Showing +import dev.nodeterm.protocol.host.TerminalPage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Audit A45: the terminal WebView's renderer can go away (killed by the system, or crashed), and the + * screen then builds a new WebView. The screen (TerminalController) is only type-checked; these pin + * the rules it delegates to: which page's callbacks count, where queued JavaScript goes, who + * reattaches, and what the screen shows meanwhile. What it passes in, and when it builds the new + * WebView, is pinned in its source at the end. + */ +class TerminalPageTest { + // ---- the page generation ----------------------------------------------------------------- + + @Test + fun `JavaScript offered before the page is ready is run in order once it is`() { + val page = TerminalPage() + val gen = page.build() + assertFalse(page.offer("a")) + assertFalse(page.offer("b")) + assertEquals(listOf("a", "b"), page.ready(gen)) + assertTrue(page.isReady) + assertTrue(page.offer("c"), "a ready page runs JavaScript at once") + assertEquals(emptyList(), page.ready(gen), "a second ready hands nothing over again") + } + + @Test + fun `a lost page's late ready neither marks the next page ready nor takes its queue`() { + val page = TerminalPage() + val dead = page.build() + page.lost() + val next = page.build() + assertFalse(page.offer("paint")) + // The dead page's onReady, posted before the loss and run after it. + assertNull(page.ready(dead)) + assertFalse(page.isReady) + assertFalse(page.isCurrent(dead)) + assertTrue(page.isCurrent(next)) + assertEquals(listOf("paint"), page.ready(next), "the queue is still there for the page it was meant for") + } + + @Test + fun `the renderer going away before the page was ready drops what was queued for it`() { + val page = TerminalPage() + val dead = page.build() + page.offer("nt.write(old output)") + page.lost() + assertFalse(page.isCurrent(dead), "the dead page's callbacks no longer count") + assertTrue(page.isReplacing) + val next = page.build() + page.offer("nt.setFontSize(13)") + assertEquals(listOf("nt.setFontSize(13)"), page.ready(next)) + assertFalse(page.isReplacing) + } + + @Test + fun `a ready page that is lost stops running JavaScript until its replacement is ready`() { + val page = TerminalPage() + page.ready(page.build()) + assertTrue(page.offer("x")) + page.lost() + assertFalse(page.isReady) + assertFalse(page.offer("y"), "no page to run it on: queued, not evaluated on a destroyed WebView") + } + + @Test + fun `JavaScript offered while there is no page at all is kept for the next one`() { + val page = TerminalPage() + page.ready(page.build()) + page.lost() + // A reattach can land and paint before the replacement WebView has been built. + assertFalse(page.offer("nt.paint(screen)")) + val next = page.build() + page.offer("nt.setFontSize(13)") + assertEquals(listOf("nt.paint(screen)", "nt.setFontSize(13)"), page.ready(next)) + } + + @Test + fun `only a replacement page is waited for, not the first one`() { + val page = TerminalPage() + val first = page.build() + assertFalse(page.isReplacing, "the first attach is not held back for the first page") + page.ready(first) + page.lost() + val next = page.build() + assertTrue(page.isReplacing) + page.ready(next) + assertFalse(page.isReplacing) + } + + // ---- who reattaches -------------------------------------------------------------------- + + private fun RendererRecovery.gone(didCrash: Boolean, now: Long) = onGone(didCrash, now, Showing.ATTACHED, visible = true) + + @Test + fun `a crashed renderer is offered to the user, never reattached by itself`() { + val recovery = RendererRecovery() + assertIs(recovery.gone(didCrash = true, now = 0)) + assertIs(recovery.gone(didCrash = true, now = 3_600_000)) + assertIs(recovery.onGone(didCrash = true, now = 3_600_001, showing = Showing.OPENING, visible = true)) + } + + @Test + fun `a renderer the system killed is reattached by itself`() { + assertEquals(Outcome.Reattach, RendererRecovery().gone(didCrash = false, now = 0)) + assertEquals(Outcome.Reattach, RendererRecovery().onGone(didCrash = false, now = 0, showing = Showing.OPENING, visible = true)) + } + + @Test + fun `repeated kills fall back to the offer, and recover once the window has passed`() { + val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 2) + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 0)) + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 10_000)) + assertIs(recovery.gone(didCrash = false, now = 20_000)) + assertIs(recovery.gone(didCrash = false, now = 59_999)) + // The first automatic reattach has left the window: one more is allowed. + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 60_000)) + assertIs(recovery.gone(didCrash = false, now = 60_001)) + } + + @Test + fun `kills while the screen is not visible are reattached and do not use up the automatic reattaches`() { + // The renderer's priority is waived while the screen is not visible, so Android reclaiming it + // then is expected (the review of A45); the screen reattaches when it is started again. + val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 2) + repeat(5) { + assertEquals(Outcome.Reattach, recovery.onGone(didCrash = false, now = it.toLong(), showing = Showing.OPENING, visible = false)) + } + // Back in front of the user: the whole bound is still there. + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 10)) + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 11)) + assertIs(recovery.gone(didCrash = false, now = 12)) + // A background kill is not held against the screen once the bound is used up either. + assertEquals(Outcome.Reattach, recovery.onGone(didCrash = false, now = 13, showing = Showing.OPENING, visible = false)) + } + + @Test + fun `a crash while the screen is not visible is still not reattached by itself`() { + // Only a kill is expected in the background; a crash may come from what the pane printed. + val crashed = assertIs(RendererRecovery().onGone(didCrash = true, now = 0, showing = Showing.OPENING, visible = false)) + assertEquals("The terminal view crashed.", crashed.message) + } + + @Test + fun `an offer is not counted as an automatic reattach`() { + val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 1) + assertIs(recovery.gone(didCrash = true, now = 0)) + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 1)) + } + + // ---- what the screen was showing (A45 review) -------------------------------------------- + + @Test + fun `an answer on screen is kept, whether the renderer was killed or crashed`() { + // "The session ended (exit 1).", "Disconnected.", the relay offer, or an earlier "Reopen + // terminal": the screen keeps it and its button. A kill must not turn an ended session into an + // attach of its own (over the relay that creates a new, empty session), and a kill after a + // crash offer must not reattach what the user was asked about. + val recovery = RendererRecovery() + assertEquals(Outcome.Keep, recovery.onGone(didCrash = false, now = 0, showing = Showing.SETTLED, visible = true)) + assertEquals(Outcome.Keep, recovery.onGone(didCrash = true, now = 1, showing = Showing.SETTLED, visible = true)) + } + + @Test + fun `a kept answer does not use up the automatic reattaches`() { + val recovery = RendererRecovery(windowMs = 60_000, maxAutomatic = 2) + repeat(5) { recovery.onGone(didCrash = false, now = it.toLong(), showing = Showing.SETTLED, visible = true) } + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 10)) + assertEquals(Outcome.Reattach, recovery.gone(didCrash = false, now = 11)) + assertIs(recovery.gone(didCrash = false, now = 12)) + } + + @Test + fun `the offer says the session is running only when a stream was attached`() { + val running = "The session is still running on the computer." + val crashed = assertIs(RendererRecovery().gone(didCrash = true, now = 0)) + assertEquals("The terminal view crashed. $running", crashed.message) + val opening = assertIs(RendererRecovery().onGone(didCrash = true, now = 0, showing = Showing.OPENING, visible = true)) + assertEquals("The terminal view crashed.", opening.message) + assertFalse(running in opening.message, "nothing was known about the session while the terminal was opening") + + val loop = RendererRecovery(maxAutomatic = 0) + val killedAttached = assertIs(loop.gone(didCrash = false, now = 0)) + assertEquals("Android keeps closing the terminal view to free memory. $running", killedAttached.message) + val killedOpening = assertIs(loop.onGone(didCrash = false, now = 1, showing = Showing.OPENING, visible = true)) + assertEquals("Android keeps closing the terminal view to free memory.", killedOpening.message) + } + + // ---- the screen's side, pinned in the app's source (the review of A45) ---------------------- + + private val controller get() = AppSourcePins.ui("TerminalController.kt") + + @Test + fun `the screen asks on a monotonic clock and says whether it is visible`() { + val gone = AppSourcePins.blockAfter(controller, "private fun rendererGone(") + assertTrue( + gone.contains("recovery.onGone(didCrash, SystemClock.elapsedRealtime(), showing, visible = !stopped)"), + "rendererGone does not ask RendererRecovery with the monotonic clock and the screen's visibility:\n$gone" + ) + assertFalse(gone.contains("currentTimeMillis"), "the kill-loop window runs on the wall clock:\n$gone") + } + + @Test + fun `a lost page is rebuilt at once only for an automatic reattach of a visible screen`() { + val src = controller + val gone = AppSourcePins.blockAfter(src, "private fun rendererGone(") + AppSourcePins.assertInOrder(gone, "webViewKey++", "recovery.onGone(", "hasWebView = outcome == RendererRecovery.Outcome.Reattach && !stopped") + // Otherwise the next attach asked for builds it, and only that: an offer (or a kept answer) + // rebuilt at once let a page that dies as it loads be rebuilt and lost with no bound. + AppSourcePins.assertInOrder( + AppSourcePins.blockAfter(src, "private fun attachWhenPageReady()"), + "if (page.isReplacing) {", + "if (!stopped) hasWebView = true", + "state = TermState.Connecting" + ) + assertEquals(2, Regex("""hasWebView\s*=(?!=)""").findAll(src).count(), "hasWebView is set somewhere else") + // "Reopen terminal" and coming back to the screen are among the attaches that build it. + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(src, "fun reopenTerminal()"), "attachWhenPageReady()") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(src, "fun onStart()"), "stopped = false", "attachWhenPageReady()") + // No WebView at all meanwhile: the dead one left with its key. + AppSourcePins.assertInOrder( + AppSourcePins.ui("TerminalScreen.kt"), + "if (controller.hasWebView) {", + "key(controller.webViewKey) {", + "AndroidView(factory = { ctx -> controller.createWebView(ctx) }" + ) + } + + @Test + fun `the visible terminal's renderer keeps WebView's default priority`() { + // RENDERER_PRIORITY_BOUND waived it in the background too, but also bound the renderer of the + // terminal the user was looking at below the default. + val create = AppSourcePins.blockAfter(controller, "fun createWebView(") + assertTrue(create.contains("setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_IMPORTANT, true)"), create) + assertFalse(controller.contains("setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_BOUND")) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalScrollActionsTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalScrollActionsTest.kt new file mode 100644 index 0000000000..4c4e5533b1 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalScrollActionsTest.kt @@ -0,0 +1,106 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.* +import kotlinx.coroutines.* +import kotlin.test.* + +class TerminalScrollActionsTest { + private val history = TerminalScrollView.Result.History("b3b8e879-6449-4c8c-8529-4c02d6885878", 1, 2, 80, + listOf(TerminalScrollView.Row("old", false, "normal")), false, false, true) + private class Stream : TerminalStream { + override val fresh = false + val log = mutableListOf() + var clearCount = 0 + var result: suspend () -> TerminalScrollView.Result = { TerminalScrollView.Result.Input } + override fun write(text: String) { log += "input:$text" } + override fun resize(cols: Int, rows: Int) {} + override suspend fun scroll(up: Boolean, lines: Int) { error("untyped path must not run") } + override suspend fun scrollView(up: Boolean, lines: Int): TerminalScrollView.Result { log += "$up:$lines"; return result() } + override fun clearScrollView() { clearCount++ } + override suspend fun detach() {} + override suspend fun endSession() {} + } + @Test fun `ordered chunks reversal and display epochs survive history responses`() = runBlocking { + val stream = Stream().apply { result = { history } } + val displays = mutableListOf>() + val actor = TerminalActions(this, stream, { _, epoch, display -> displays += epoch to display }) { true } + try { + assertTrue(actor.scroll(true, 45, 7)); assertTrue(actor.scroll(false, 3, 7)); assertTrue(actor.scroll(true, 2, 8)) + yield() + assertEquals(listOf("true:20", "true:20", "true:5", "false:3", "true:2"), stream.log) + assertEquals(listOf(7, 7, 7, 7, 8), displays.map { it.second }) + assertEquals(0, stream.clearCount) + } finally { actor.close() } + } + @Test fun `explicit input suppresses an awaited page and invalidates already posted pages`() = runBlocking { + val entered = CompletableDeferred(); val release = CompletableDeferred() + val stream = Stream().apply { result = { entered.complete(Unit); release.await(); history } } + val displays = mutableListOf() + val actor = TerminalActions(this, stream, { _, epoch, _ -> displays += epoch }) { true } + try { + actor.scroll(true, 40); entered.await(); actor.write("x"); release.complete(Unit); yield() + assertTrue(displays.isEmpty(), "An in-flight pre-input page must not reopen history") + assertEquals(listOf("true:20", "input:x"), stream.log) + stream.result = { history }; actor.scroll(true, 1); yield() + val posted = displays.single(); assertEquals(posted, actor.scrollEpoch) + actor.write("y"); assertNotEquals(posted, actor.scrollEpoch, "The main-thread delivery fence must invalidate a queued page too") + yield() + } finally { actor.close() } + } + @Test fun `automatic reports and a new touch preserve the awaited page and token`() = runBlocking { + val entered = CompletableDeferred(); val release = CompletableDeferred() + val stream = Stream().apply { result = { entered.complete(Unit); release.await(); history } } + val displays = mutableListOf() + val actor = TerminalActions(this, stream, { result, _, _ -> displays += result }) { true } + try { + actor.scroll(true, 40); entered.await(); actor.report("reply"); actor.cancelScroll() + release.complete(Unit); yield() + assertEquals(listOf(history), displays); assertEquals(listOf("true:20", "input:reply"), stream.log) + assertEquals(0, stream.clearCount) + } finally { actor.close() } + } + @Test fun `refusal uncertainty and thrown failure stop remaining momentum without replay`() = runBlocking { + for (failure in listOf(TerminalScrollView.Result.Refused("old host"), TerminalScrollView.Result.Uncertain("lost result"), null)) { + val stream = Stream().apply { result = { failure ?: throw IllegalStateException("lost") } } + val displays = mutableListOf() + val actor = TerminalActions(this, stream, { result, _, _ -> displays += result }) { true } + try { + actor.scroll(true, 60); actor.report("reply"); actor.scroll(false, 5); yield() + assertEquals(listOf("true:20", "input:reply"), stream.log) + assertEquals(1, displays.size) + assertTrue(displays.single() is TerminalScrollView.Result.Refused || displays.single() is TerminalScrollView.Result.Uncertain) + assertFalse(actor.scroll(true, 3), "Late frames from the failed coast must not start another input-capable RPC") + assertTrue(actor.cancelScroll(), "The next touch is a new explicit gesture") + stream.result = { TerminalScrollView.Result.Input }; actor.scroll(false, 2); yield() + assertEquals("false:2", stream.log.last(), "A new explicit gesture remains usable") + } finally { actor.close() } + } + } + @Test fun `retired and obsolete viewers never publish their awaited history`() = runBlocking { + for (close in listOf(false, true)) { + val entered = CompletableDeferred(); val release = CompletableDeferred(); var current = true + val stream = Stream().apply { result = { entered.complete(Unit); release.await(); history } } + val displays = mutableListOf() + val actor = TerminalActions(this, stream, { result, _, _ -> displays += result }) { current } + actor.scroll(true, 30); entered.await() + if (close) actor.close() else current = false + release.complete(Unit); yield(); assertTrue(displays.isEmpty()); actor.close() + } + } + @Test fun `an old failed reply cannot cancel movement admitted after an input barrier`() = runBlocking { + val entered = CompletableDeferred(); val release = CompletableDeferred() + var first = true + val stream = Stream().apply { result = { + if (first) { first = false; entered.complete(Unit); release.await(); error("old reply failed") } + TerminalScrollView.Result.Input + } } + val actor = TerminalActions(this, stream) { true } + try { + actor.scroll(true, 40); entered.await() + actor.write("x"); assertTrue(actor.scroll(false, 3, 1)) + release.complete(Unit); yield() + assertEquals(listOf("true:20", "input:x", "false:3"), stream.log) + assertFalse(actor.scrollPaused) + } finally { actor.close() } + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalScrollViewTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalScrollViewTest.kt new file mode 100644 index 0000000000..527d3790a4 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalScrollViewTest.kt @@ -0,0 +1,54 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.TerminalScrollView +import kotlinx.serialization.json.* +import kotlin.test.* + +class TerminalScrollViewTest { + private fun page(change: JsonObjectBuilder.() -> Unit = {}) = buildJsonObject { + put("status", "history"); put("viewId", "b3b8e879-6449-4c8c-8529-4c02d6885878") + put("offset", 3); put("totalRows", 10); put("cols", 80) + put("olderTruncated", true); put("hasOlder", true); put("hasNewer", true) + put("rows", buildJsonArray { repeat(4) { add(buildJsonObject { + put("text", "row $it"); put("isWrapped", it == 1); put("section", if (it < 2) "normal" else "alternate") + }) } }); change() + } + @Test fun `physical rows retain wraps sections truncation and exact opaque identity`() { + val parsed = assertIs(TerminalScrollView.parse(page())) + assertEquals(3, parsed.offset); assertEquals(listOf("normal", "normal", "alternate", "alternate"), parsed.rows.map { it.section }) + assertTrue(parsed.rows[1].isWrapped); assertTrue(parsed.olderTruncated) + assertEquals(parsed, TerminalScrollView.parse(Json.parseToJsonElement(parsed.json()))) + } + @Test fun `offset counts back from live and both boundary flags must agree`() { + assertNotNull(TerminalScrollView.parse(page { put("offset", 0); put("hasNewer", false) })) + assertNotNull(TerminalScrollView.parse(page { put("offset", 6); put("hasOlder", false) })) + assertNull(TerminalScrollView.parse(page { put("offset", 7) })) + assertNull(TerminalScrollView.parse(page { put("hasOlder", false) })) + assertNull(TerminalScrollView.parse(page { put("hasNewer", false) })) + assertNotNull(TerminalScrollView.parse(page { put("totalRows", 4); put("offset", 0); put("hasOlder", false); put("hasNewer", false) })) + } + @Test fun `wrong primitive types malformed UUID bounds and controls never become pages`() { + for ((key, value) in listOf("offset" to JsonPrimitive("3"), "cols" to JsonPrimitive(0), + "cols" to JsonPrimitive(65536), "totalRows" to JsonPrimitive(8193), "viewId" to JsonPrimitive("someone-elses-view"), + "hasOlder" to JsonPrimitive("true"))) assertNull(TerminalScrollView.parse(page { put(key, value) }), key) + for (bad in listOf("\u001b[31m", "\n", "\u009b31m")) assertNull(TerminalScrollView.parse(page { + put("rows", buildJsonArray { add(buildJsonObject { put("text", bad); put("isWrapped", false); put("section", "normal") }) }) + })) + } + @Test fun `both physical row count and UTF8 byte budget are enforced`() { + fun many(count: Int, text: String) = page { + put("rows", buildJsonArray { repeat(count) { add(buildJsonObject { put("text", text); put("isWrapped", false); put("section", "normal") }) } }) + put("totalRows", count); put("offset", 0); put("hasOlder", false); put("hasNewer", false) + } + assertNotNull(TerminalScrollView.parse(many(200, "x"))) + assertNull(TerminalScrollView.parse(many(201, "x"))) + assertNull(TerminalScrollView.parse(many(100, "界".repeat(1000)))) + } + @Test fun `input refusal uncertainty remain distinct and unknown replies never imply input`() { + assertEquals(TerminalScrollView.Result.Input, TerminalScrollView.parse(buildJsonObject { put("status", "input") })) + assertIs(TerminalScrollView.parse(buildJsonObject { put("status", "refused"); put("message", "Old host") })) + assertIs(TerminalScrollView.parse(buildJsonObject { put("status", "uncertain"); put("message", "Not confirmed") })) + assertNull(TerminalScrollView.parse(buildJsonObject { put("status", "ok") })) + assertNull(TerminalScrollView.parse(buildJsonObject { put("status", "refused"); put("message", 42) })) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalSearchTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalSearchTest.kt new file mode 100644 index 0000000000..8d454aa38a --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalSearchTest.kt @@ -0,0 +1,102 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.TerminalCopy +import dev.nodeterm.protocol.model.TerminalCopy.Match +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Search the captured output without typing into the remote terminal or changing a copy selection. */ +class TerminalSearchTest { + @Test + fun `matching is literal and case insensitive with exact row offsets`() { + val lines = listOf("ok", "[ERROR]. boom [error].", "error without punctuation") + assertEquals(listOf(Match(1, 0, 8), Match(1, 14, 22)), TerminalCopy.search(lines, "[error].").matches) + assertEquals(listOf(Match(1, 7, 8), Match(1, 21, 22)), TerminalCopy.search(lines, ".").matches) + assertEquals(emptyList(), TerminalCopy.search(lines, "[unclosed").matches) + } + + @Test + fun `empty and blank queries never match all output`() { + for (query in listOf("", " ", "\t\n")) { + assertEquals(TerminalCopy.Search(emptyList()), TerminalCopy.search(listOf("text", " "), query)) + } + assertEquals(emptyList(), TerminalCopy.search(emptyList(), "x").matches) + assertEquals(emptyList(), TerminalCopy.search(listOf("tiny"), "a much longer query").matches) + } + + @Test + fun `spaces within a query are significant and occurrences do not overlap`() { + assertEquals(listOf(Match(0, 1, 4)), TerminalCopy.search(listOf("x a x", "a"), " a ").matches) + assertEquals(listOf(Match(0, 0, 2), Match(0, 2, 4)), TerminalCopy.search(listOf("aaaaa"), "aa").matches) + } + + @Test + fun `Unicode matching preserves original UTF16 highlighting offsets`() { + assertEquals(listOf(Match(0, 3, 8), Match(0, 12, 17)), TerminalCopy.search(listOf("🚀 ÄRGER 🚀 ärger"), "ärger").matches) + assertEquals(listOf(Match(0, 0, 2), Match(0, 9, 11)), TerminalCopy.search(listOf("🚀 ÄRGER 🚀 ärger"), "🚀").matches) + } + + @Test + fun `matching repeated text preserves original line identity and copy order`() { + val lines = listOf("error old", "ok", "error new", "error new") + val selected = TerminalCopy.Selection(setOf(2, 1), anchor = 2) + val result = TerminalCopy.search(lines, "error") + assertEquals(listOf(0, 2, 3), result.matches.map { it.line }) + assertEquals(TerminalCopy.Text.Copy("ok\nerror new", 2), TerminalCopy.text(lines, selected)) + assertEquals(2, result.matches[result.move(0, forward = true)!!].line) + } + + @Test + fun `navigation wraps in both directions and stale cursors choose the appropriate edge`() { + val result = TerminalCopy.search(listOf("x x x"), "x") + assertEquals(0, result.move(null, forward = true)) + assertEquals(2, result.move(null, forward = false)) + assertEquals(1, result.move(0, forward = true)) + assertEquals(0, result.move(2, forward = true)) + assertEquals(2, result.move(0, forward = false)) + assertEquals(1, result.move(2, forward = false)) + assertEquals(0, result.move(99, forward = true)) + assertEquals(2, result.move(-1, forward = false)) + assertNull(TerminalCopy.search(listOf("ok"), "x").move(0, forward = true)) + assertEquals(0, TerminalCopy.search(listOf("x"), "x").move(0, forward = false)) + } + + @Test + fun `the match cap reports truncation only when another occurrence actually exists`() { + val cap = TerminalCopy.MAX_SEARCH_MATCHES + val atCap = TerminalCopy.search(listOf("x".repeat(cap)), "x") + assertEquals(cap, atCap.matches.size) + assertFalse(atCap.truncated) + val over = TerminalCopy.search(listOf("x".repeat(cap), "x"), "x") + assertEquals(cap, over.matches.size) + assertTrue(over.truncated) + assertEquals(Match(0, cap - 1, cap), over.matches.last()) + } + + @Test + fun `native search uses captured rows and offsets without changing selection or sending input`() { + val screen = AppSourcePins.ui("TerminalScreen.kt") + val sheet = AppSourcePins.blockAfter(screen, "private fun CopySheet(") + AppSourcePins.assertInOrder(sheet, "var query by remember(snapshot)", "TerminalCopy.search(snapshot.lines, query)") + assertTrue("var matchIndex by remember(snapshot, query)" in sheet, "A different query must reset its match cursor") + assertTrue("listState.scrollToItem(linkRows + it.line)" in sheet, "Links before the text must not offset search to the wrong row") + assertTrue("itemsIndexed(snapshot.lines, key = { i, _ -> \"line:\$i\" })" in sheet, "Search must retain the original rows used by copy and range selection") + assertTrue("match.start, match.end" in sheet, "Highlights must retain the model's UTF16 offsets") + assertTrue("No matches in captured output" in sheet) + assertTrue("Clear search" in sheet) + assertTrue("Text(\"Previous\")" in sheet && "Text(\"Next\")" in sheet) + assertFalse("controller.raw(" in sheet || "controller.key(" in sheet, "Local search must not send a query to the remote shell") + val change = AppSourcePins.blockAfter(sheet, "onValueChange =") + assertEquals("{ query = it }", change.trim()) + } + + @Test + fun `the Find chip opens host history search without typing`() { + val chip = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalScreen.kt"), "KeyChip(\"Find\")") + AppSourcePins.assertInOrder(chip, "focusManager.clearFocus()", "controller.openHistory()") + assertFalse("controller.raw(" in chip || "controller.key(" in chip) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalSendStateTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalSendStateTest.kt new file mode 100644 index 0000000000..e390ac19d0 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalSendStateTest.kt @@ -0,0 +1,203 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.host.ComposedCompletion +import dev.nodeterm.protocol.host.ComposedInput +import dev.nodeterm.protocol.host.ComposedInputResult +import dev.nodeterm.protocol.host.TerminalActions +import dev.nodeterm.protocol.host.TerminalStream +import dev.nodeterm.protocol.model.CtrlModifier +import dev.nodeterm.protocol.model.TerminalDrafts +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** Real entry/actor policy, with deferred host calls; no Compose, Android Handler or physical claims. */ +class TerminalSendStateTest { + private data class Editor(val text: String = "", val start: Int = 0, val end: Int = start) + private val empty = Editor() + + @Test fun `the exact accepted editor and modifier are reserved before preparation and survive coverage`() { + val drafts = TerminalDrafts(empty); val entry = drafts.entry("terminal", "host") + entry.edit(Editor("command Ω🧭", 2, 7)); entry.setCtrl(CtrlModifier().withArmed(true)) + val before = entry.state.value; val sent = assertNotNull(entry.beginSend()) + assertSame(before.value, sent.value); assertEquals(before.revision, sent.revision); assertSame(before.ctrl, sent.ctrl) + entry.edit(Editor("new unsent", 3)); entry.setCtrl(entry.state.value.ctrl.withArmed(false)) + drafts.retain(setOf("terminal")); val remounted = drafts.entry("terminal", "host") + assertSame(entry, remounted); assertSame(sent.attempt, remounted.state.value.pendingSend) + assertNull(remounted.beginSend(), "both button and IME admission must refuse another pending attempt") + assertTrue(entry.completeSend(sent.attempt, ComposedInputResult.uncertain(), false)) + assertNotNull(remounted.beginSend()) + } + + @Test fun `only the exact pending token can complete once and old results cannot end a newer Send`() { + val drafts = TerminalDrafts(empty); val entry = drafts.entry("a", "host"); val other = drafts.entry("b", "host") + val first = assertNotNull(entry.beginSend()); val foreign = assertNotNull(other.beginSend()) + assertFalse(entry.completeSend(foreign.attempt, ComposedInputResult.DELIVERED, true)) + assertSame(first.attempt, entry.state.value.pendingSend) + assertTrue(entry.completeSend(first.attempt, ComposedInputResult.uncertain(), false)) + val second = assertNotNull(entry.beginSend()); val before = entry.state.value + assertFalse(entry.completeSend(first.attempt, ComposedInputResult.DELIVERED, true)) + assertSame(before, entry.state.value); assertSame(second.attempt, entry.state.value.pendingSend) + assertTrue(entry.completeSend(second.attempt, ComposedInputResult.DELIVERED, true)) + assertFalse(entry.completeSend(second.attempt, ComposedInputResult.refused(), true)) + } + + @Test fun `a stale positive becomes visible uncertainty without clearing a newer draft or Ctrl`() { + val entry = TerminalDrafts(empty).entry("a", "host") + entry.edit(Editor("sent", 4)); val sent = assertNotNull(entry.beginSend()) + val completion = ComposedCompletion(sent.ctrl.revision) + entry.edit(Editor("next unsent", 2, 6)); entry.setCtrl(entry.state.value.ctrl.withArmed(true)) + assertTrue(entry.completeSend(sent.attempt, ComposedInputResult.DELIVERED, false)) + val notice = assertNotNull(entry.state.value.sendNotice) + assertEquals(ComposedInputResult.Status.UNCERTAIN, notice.status) + assertTrue(notice.message.contains("check the terminal", ignoreCase = true)) + assertFalse(completion.complete(ComposedInputResult.DELIVERED, false, entry.state.value.ctrl.revision, + { entry.setCtrl(entry.state.value.ctrl.withArmed(false)) }, { entry.clearUnchangedDraft(sent.revision) })) + assertEquals(Editor("next unsent", 2, 6), entry.state.value.value); assertTrue(entry.state.value.ctrl.armed) + } + + @Test fun `known refusal stays refused after view retirement and releases its pending reservation`() { + val entry = TerminalDrafts(empty).entry("a", "host"); entry.edit(Editor("kept", 1)) + val sent = assertNotNull(entry.beginSend()) + assertTrue(entry.completeSend(sent.attempt, ComposedInputResult.refused("Not dispatched"), false)) + assertNull(entry.state.value.pendingSend) + assertEquals(ComposedInputResult.Status.REFUSED, entry.state.value.sendNotice?.status) + assertEquals("Not dispatched", entry.state.value.sendNotice?.message) + assertEquals(Editor("kept", 1), entry.state.value.value) + } + + @Test fun `uncertainty survives edits modifier changes remount and later success until explicitly dismissed`() { + val drafts = TerminalDrafts(empty); val entry = drafts.entry("a", "host") + val sent = assertNotNull(entry.beginSend()); entry.completeSend(sent.attempt, ComposedInputResult.uncertain(), false) + val notice = assertNotNull(entry.state.value.sendNotice) + entry.edit(Editor("later", 1, 3)); entry.setCtrl(CtrlModifier().withArmed(true)) + drafts.retain(setOf("a")); assertSame(notice, drafts.entry("a", "host").state.value.sendNotice) + val later = assertNotNull(entry.beginSend()); entry.completeSend(later.attempt, ComposedInputResult.DELIVERED, true) + assertSame(notice, entry.state.value.sendNotice) + assertTrue(entry.dismissSendNotice(notice)); assertNull(entry.state.value.sendNotice) + assertFalse(entry.completeSend(sent.attempt, ComposedInputResult.uncertain(), false)) + assertNull(entry.state.value.sendNotice, "a duplicate old result cannot resurrect dismissed guidance") + } + + @Test fun `a stale dismissal cannot hide an identical notice from a newer attempt`() { + val entry = TerminalDrafts(empty).entry("a", "host") + val first = assertNotNull(entry.beginSend()); entry.completeSend(first.attempt, ComposedInputResult.uncertain(), false) + val old = assertNotNull(entry.state.value.sendNotice) + val second = assertNotNull(entry.beginSend()); entry.completeSend(second.attempt, ComposedInputResult.uncertain(), false) + val latest = assertNotNull(entry.state.value.sendNotice); assertEquals(old.message, latest.message) + assertFalse(entry.dismissSendNotice(old)); assertSame(latest, entry.state.value.sendNotice) + assertTrue(entry.dismissSendNotice(latest)) + } + + @Test fun `a later known refusal cannot erase earlier uncertainty before explicit dismissal`() { + val entry = TerminalDrafts(empty).entry("a", "host") + val first = assertNotNull(entry.beginSend()); entry.completeSend(first.attempt, ComposedInputResult.uncertain(), false) + val uncertain = assertNotNull(entry.state.value.sendNotice) + val second = assertNotNull(entry.beginSend()) + assertTrue(entry.completeSend(second.attempt, ComposedInputResult.refused("Invalid later Send"), true)) + assertNull(entry.state.value.pendingSend); assertSame(uncertain, entry.state.value.sendNotice) + assertEquals(ComposedInputResult.Status.UNCERTAIN, entry.state.value.sendNotice?.status) + assertTrue(entry.dismissSendNotice(uncertain)) + val third = assertNotNull(entry.beginSend()); entry.completeSend(third.attempt, ComposedInputResult.refused("Not dispatched"), false) + assertEquals(ComposedInputResult.Status.REFUSED, entry.state.value.sendNotice?.status) + assertEquals("Not dispatched", entry.state.value.sendNotice?.message) + } + + @Test fun `pop forget and owner replacement release all Send state and seal old handles`() { + for (retire in listOf<(TerminalDrafts) -> Unit>({ it.retain(emptySet()) }, + { it.retireOwner("host") }, { it.entry("a", "different host") })) { + val drafts = TerminalDrafts(empty); val old = drafts.entry("a", "host") + old.edit(Editor("private")); val first = assertNotNull(old.beginSend()) + old.completeSend(first.attempt, ComposedInputResult.uncertain(), false) + val notice = assertNotNull(old.state.value.sendNotice); val pending = assertNotNull(old.beginSend()) + retire(drafts) + assertNull(old.state.value.pendingSend); assertNull(old.state.value.sendNotice); assertEquals(empty, old.state.value.value) + assertNull(old.beginSend()); assertFalse(old.completeSend(pending.attempt, ComposedInputResult.DELIVERED, true)) + assertFalse(old.dismissSendNotice(notice)) + val next = drafts.entry("a", "host"); val fresh = assertNotNull(next.beginSend()) + assertFalse(next.completeSend(pending.attempt, ComposedInputResult.DELIVERED, true)) + assertSame(fresh.attempt, next.state.value.pendingSend) + } + } + + @Test fun `two live entries keep independent pending state and guidance`() { + val drafts = TerminalDrafts(empty); val a = drafts.entry("a", "host"); val b = drafts.entry("b", "host") + val sa = assertNotNull(a.beginSend()); val sb = assertNotNull(b.beginSend()) + a.completeSend(sa.attempt, ComposedInputResult.uncertain(), false) + assertNull(a.state.value.pendingSend); assertNotNull(a.state.value.sendNotice) + assertSame(sb.attempt, b.state.value.pendingSend); assertNull(b.state.value.sendNotice) + } + + private class Stream : TerminalStream { + override val fresh = false + val inputs = mutableListOf() + var retired = false + var onScroll: suspend () -> Unit = {} + var onSubmit: suspend () -> ComposedInputResult = { ComposedInputResult.DELIVERED } + override fun write(text: String) {} + override fun resize(cols: Int, rows: Int) {} + override suspend fun scroll(up: Boolean, lines: Int) { onScroll() } + override suspend fun submitComposed(input: ComposedInput): ComposedInputResult { inputs += input; return onSubmit() } + override fun retireComposed() { retired = true } + override suspend fun detach() {} + override suspend fun endSession() {} + } + + @Test fun `covering a dispatched actor retains pending until its exact uncertainty settles without replay`() = runBlocking { + val drafts = TerminalDrafts(empty); val entry = drafts.entry("a", "host"); entry.edit(Editor("command", 7)) + val stream = Stream(); val started = CompletableDeferred(); val release = CompletableDeferred() + stream.onSubmit = { started.complete(Unit); release.await(); ComposedInputResult.DELIVERED } + var current = true; val actor = TerminalActions(this, stream) { current } + val sent = assertNotNull(entry.beginSend()) + val receipt = async(start = CoroutineStart.UNDISPATCHED) { actor.submit(ComposedInput.Paste(sent.value.text, true)) } + try { + withTimeout(2_000) { started.await() }; current = false; actor.close(); drafts.retain(setOf("a")) + val remounted = drafts.entry("a", "host") + assertNull(remounted.beginSend()); assertSame(sent.attempt, remounted.state.value.pendingSend) + val result = receipt.await(); assertEquals(ComposedInputResult.Status.UNCERTAIN, result.status) + assertTrue(remounted.completeSend(sent.attempt, result, false)); assertTrue(stream.retired) + assertNull(remounted.state.value.pendingSend); assertEquals(ComposedInputResult.Status.UNCERTAIN, remounted.state.value.sendNotice?.status) + release.complete(Unit); yield(); assertEquals(listOf(ComposedInput.Paste("command", true)), stream.inputs) + assertEquals(Editor("command", 7), remounted.state.value.value) + } finally { release.complete(Unit); actor.close() } + } + + @Test fun `covering a queued actor retains a known refusal with no host execution`() = runBlocking { + val entry = TerminalDrafts(empty).entry("a", "host"); entry.edit(Editor("unsent", 6)) + val stream = Stream(); val started = CompletableDeferred(); val release = CompletableDeferred() + stream.onScroll = { started.complete(Unit); release.await() } + val actor = TerminalActions(this, stream) { true } + try { + actor.scroll(true, 20); withTimeout(2_000) { started.await() } + val sent = assertNotNull(entry.beginSend()) + val receipt = async(start = CoroutineStart.UNDISPATCHED) { actor.submit(ComposedInput.Paste(sent.value.text, true)) } + actor.close(); val result = receipt.await() + assertEquals(ComposedInputResult.Status.REFUSED, result.status) + assertTrue(entry.completeSend(sent.attempt, result, false)) + assertEquals(ComposedInputResult.Status.REFUSED, entry.state.value.sendNotice?.status) + assertEquals(Editor("unsent", 6), entry.state.value.value); assertTrue(stream.inputs.isEmpty()) + } finally { release.complete(Unit); actor.close() } + } + + @Test fun `a current confirmed attempt settles then clears only its unchanged draft and captured Ctrl`() { + val entry = TerminalDrafts(empty).entry("a", "host"); entry.edit(Editor("sent", 4)) + entry.setCtrl(CtrlModifier().withArmed(true)); val sent = assertNotNull(entry.beginSend()) + val policy = ComposedCompletion(sent.ctrl.revision) + assertTrue(entry.completeSend(sent.attempt, ComposedInputResult.DELIVERED, true)) + assertTrue(policy.complete(ComposedInputResult.DELIVERED, true, entry.state.value.ctrl.revision, + { entry.setCtrl(entry.state.value.ctrl.withArmed(false)) }, { entry.clearUnchangedDraft(sent.revision) })) + assertEquals(empty, entry.state.value.value); assertFalse(entry.state.value.ctrl.armed) + assertNull(entry.state.value.pendingSend); assertNull(entry.state.value.sendNotice) + assertFalse(entry.completeSend(sent.attempt, ComposedInputResult.DELIVERED, true)) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalSendStateWiringTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalSendStateWiringTest.kt new file mode 100644 index 0000000000..bd77a4a86a --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalSendStateWiringTest.kt @@ -0,0 +1,41 @@ +package dev.nodeterm.protocol + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Lexical Android delegation pins; behavioral coverage is in TerminalSendStateTest. */ +class TerminalSendStateWiringTest { + @Test fun `button and IME share entry admission before JS preparation with the accepted modifier`() { + val screen = AppSourcePins.ui("TerminalScreen.kt") + val send = AppSourcePins.blockAfter(screen, "val send: () -> Unit = {") + AppSourcePins.assertInOrder(send, "if (controller.attached)", "val sent = entry.beginSend()", + "if (sent != null) controller.submit(sent.value.text, enter = true, modifier = sent.ctrl", + "entry.completeSend(sent.attempt, result, current)", "entry.clearUnchangedDraft(sent.revision)") + assertTrue(screen.contains("KeyboardActions(onSend = { send() })")) + assertTrue(screen.contains("IconButton(onClick = send, enabled = controller.attached && editor.pendingSend == null && !controller.submitting)")) + val submit = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "fun submit(text:") + AppSourcePins.assertInOrder(submit, "ComposedCompletion(modifier.revision)", "submitting = true", + "ComposedPreparation.cancelMomentum", "view.evaluateJavascript", "graph.scope.launch", + "withTimeoutOrNull(3_000)", "actor.submit(input) else ComposedInputResult.refused()", "finally {", "main.post") + } + + @Test fun `captured entry outcome settles after stale retirement separately from guarded delivery clearing`() { + val submit = AppSourcePins.blockAfter(AppSourcePins.ui("TerminalController.kt"), "fun submit(text:") + val posted = AppSourcePins.blockAfter(submit, "main.post") + AppSourcePins.assertInOrder(posted, "val current = webView === view && stream === expected && actions === actor && attached", + "if (onCompleted(completion, current))", "completionPolicy.complete(completion, current, ctrlModifier.revision, { ctrlArmed = false }, onDelivered)") + assertFalse(posted.contains("if (current)"), "retained outcome is visible even after the old controller is disposed") + assertFalse(submit.contains("notice ="), "Send outcome has one retained notice owner") + AppSourcePins.assertInOrder(AppSourcePins.blockAfter(submit, "fun refuse("), + "onCompleted(ComposedInputResult.refused(message), false)", "return false") + } + + @Test fun `retained Send guidance takes precedence over generic notices and dismisses its exact identity`() { + val screen = AppSourcePins.ui("TerminalScreen.kt") + AppSourcePins.assertInOrder(screen, "val editor by entry.state.collectAsState()", "val sendNotice = editor.sendNotice", + "(sendNotice?.message ?: controller.notice)?.let", "if (sendNotice != null) entry.dismissSendNotice(sendNotice)", + "else controller.notice = null") + assertFalse(screen.contains("controller.notice?.let"), "generic notices cannot duplicate or hide the retained Send notice") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalWebViewLayoutTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalWebViewLayoutTest.kt new file mode 100644 index 0000000000..5e7982fb1e --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/TerminalWebViewLayoutTest.kt @@ -0,0 +1,31 @@ +package dev.nodeterm.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A85: AndroidView's default WRAP_CONTENT height makes WebView force Chromium's layout height to + * zero, even when Compose measures a large terminal viewport. The percentage-height page then fits + * to one row. Native WebView layout cannot run in this protocol JVM, so pin its height policy before + * loading the page; actual rows and keyboard/rotation resizing remain device checks. + */ +class TerminalWebViewLayoutTest { + @Test + fun `the terminal WebView uses the parent dimensions before the page is loaded`() { + val source = AppSourcePins.ui("TerminalController.kt") + .replace(Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL), "") + .replace(Regex("""(?m)^\s*//.*$"""), "") + val body = AppSourcePins.blockAfter(source, "fun createWebView(") + val assignments = Regex( + """\blayoutParams\s*=\s*ViewGroup\.LayoutParams\s*\(\s*ViewGroup\.LayoutParams\.(\w+)\s*,\s*ViewGroup\.LayoutParams\.(\w+)\s*\)""" + ).findAll(body).toList() + assertEquals(1, assignments.size, "The WebView needs an explicit native height policy") + val layout = assignments.single() + assertEquals("MATCH_PARENT", layout.groupValues[1], "Terminal width follows Compose's viewport") + assertEquals("MATCH_PARENT", layout.groupValues[2], "WRAP_CONTENT forces Chromium's HTML height to zero") + val load = body.indexOf("loadUrl(") + assertTrue(load > layout.range.last, "The height policy must be set before loading terminal/index.html") + assertEquals(1, Regex("""\blayoutParams\s*=""").findAll(body).count(), "A later policy must not undo the fixed height") + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/UsagePaceTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/UsagePaceTest.kt new file mode 100644 index 0000000000..aa63b613a9 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/UsagePaceTest.kt @@ -0,0 +1,227 @@ +package dev.nodeterm.protocol + +import dev.nodeterm.protocol.model.ContextFill +import dev.nodeterm.protocol.model.ProjectsParser +import dev.nodeterm.protocol.model.UsageLimit +import dev.nodeterm.protocol.model.UsagePace +import dev.nodeterm.protocol.model.UsagePace.Pace +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** The Usages tab's pace line and the cards' context fill (audit A58, docs/mobile-usage-inbox.md). */ +class UsagePaceTest { + private val now = 1_800_000_000_000L + private val minute = 60_000L + + private fun limit( + kind: String = "session", + used: Double = 50.0, + resetsAt: Long? = now + 150 * minute, + windowMinutes: Long? = null, + group: String? = null + ) = UsageLimit(kind, group, used, null, resetsAt, windowMinutes, null, false) + + /** A snapshot measured at [at] and drawn at the same instant. */ + private fun fresh(l: UsageLimit, at: Long = now) = UsagePace.of(l, measuredAt = at, now = at) + + @Test + fun `no reset time means no pace line`() { + assertNull(fresh(limit(resetsAt = null))) + } + + @Test + fun `claude reports no window length, so session and weekly kinds default to 5h and 7d`() { + // Halfway through a 5h window (150 of 300 minutes left) with half of it used. + val session = assertNotNull(fresh(limit(kind = "session", used = 50.0, resetsAt = now + 150 * minute))) + assertEquals(300L, session.windowMinutes) + assertEquals(50.0, session.elapsedPercent, 1e-9) + assertEquals(Pace.ON_PACE, session.pace) + assertEquals("5h usage on pace", session.line) + + // One day into a week: 1/7 elapsed; 60% used is far ahead of that. + for (kind in listOf("weekly_all", "weekly_scoped")) { + val weekly = assertNotNull(fresh(limit(kind = kind, used = 60.0, resetsAt = now + 6 * 1_440 * minute)), kind) + assertEquals(10_080L, weekly.windowMinutes, kind) + assertEquals(100.0 / 7, weekly.elapsedPercent, 1e-9, kind) + assertEquals(Pace.FASTER, weekly.pace, kind) + assertEquals("7d usage pace faster", weekly.line, kind) + } + } + + @Test + fun `a reported window length wins over the kind's default and names the line`() { + // Codex reports a 7h session window: 210 of 420 minutes left = 50% elapsed. Against the + // 5h default the same resetsAt would be 30% elapsed and 50% used would read "faster". + val r = assertNotNull(fresh(limit(kind = "session", used = 50.0, resetsAt = now + 210 * minute, windowMinutes = 420))) + assertEquals(420L, r.windowMinutes) + assertEquals(Pace.ON_PACE, r.pace) + assertEquals("7h usage on pace", r.line) + } + + @Test + fun `an unknown kind gets no guessed window, but its group or a reported length is used`() { + assertNull(UsagePace.windowMinutes(limit(kind = "monthly_credits"))) + assertNull(fresh(limit(kind = "monthly_credits", resetsAt = now + minute))) + assertEquals(300L, UsagePace.windowMinutes(limit(kind = "session_opus", group = "session"))) + assertEquals(10_080L, UsagePace.windowMinutes(limit(kind = "opus_week", group = "weekly"))) + assertEquals(43_200L, UsagePace.windowMinutes(limit(kind = "monthly_credits", windowMinutes = 43_200))) + // A hand-edited non-positive length is ignored rather than dividing by it. + assertEquals(300L, UsagePace.windowMinutes(limit(kind = "session", windowMinutes = 0))) + assertEquals(300L, UsagePace.windowMinutes(limit(kind = "session", windowMinutes = -5))) + } + + @Test + fun `slower and faster are decided outside the on-pace band, inclusive at its edge`() { + // 150 of 300 minutes left: 50% elapsed. + fun paceAt(used: Double) = fresh(limit(used = used))!!.pace + assertEquals(Pace.ON_PACE, paceAt(55.0), "exactly +band is still on pace") + assertEquals(Pace.ON_PACE, paceAt(45.0), "exactly -band is still on pace") + assertEquals(Pace.FASTER, paceAt(55.1)) + assertEquals(Pace.SLOWER, paceAt(44.9)) + assertEquals("5h usage pace slower", fresh(limit(used = 10.0))!!.line) + } + + @Test + fun `a window that has reset, or is longer than the length we assumed, gets no verdict`() { + assertNull(fresh(limit(resetsAt = now)), "resets now: the percentage is the old window's") + assertNull(fresh(limit(resetsAt = now - minute)), "already reset") + // 301 minutes left of a "5h" window: the window is not five hours, so the verdict would be wrong. + assertNull(fresh(limit(resetsAt = now + 301 * minute))) + // Exactly the whole window left is the window's first instant: valid, 0% elapsed. + val start = assertNotNull(fresh(limit(used = 0.0, resetsAt = now + 300 * minute))) + assertEquals(0.0, start.elapsedPercent, 1e-9) + assertEquals(Pace.ON_PACE, start.pace) + // The last millisecond: almost all of it elapsed, and 100% used is on pace. + assertEquals(Pace.ON_PACE, fresh(limit(used = 100.0, resetsAt = now + 1))!!.pace) + } + + @Test + fun `the clocks are injected, so the same percentage measured later in the window reads differently`() { + val resetsAt = now + 300 * minute // a fresh 5h window, 60% already used + val l = limit(used = 60.0, resetsAt = resetsAt) + assertEquals(Pace.FASTER, fresh(l)!!.pace) + assertEquals(Pace.ON_PACE, fresh(l, now + 180 * minute)!!.pace, "60% elapsed, 60% used") + assertEquals(Pace.SLOWER, fresh(l, now + 270 * minute)!!.pace, "90% elapsed, 60% used") + assertNull(fresh(l, resetsAt), "and nothing once it has reset") + } + + @Test + fun `a stale snapshot is judged when it was measured, so it never drifts toward slower`() { + // Measured with 150 of 300 minutes left: 50% elapsed. Usage only grows inside a window, so + // comparing this percentage with the elapsed share at a LATER instant would understate the + // burn more the older the snapshot gets. + val measured = now + val resetsAt = measured + 150 * minute + + // One desktop poll (POLL_MS, 15 min) later: 55% elapsed now, but still 50% when measured. + val onPace = limit(used = 49.0, resetsAt = resetsAt) + val nextPoll = measured + 15 * minute + val atPoll = assertNotNull(UsagePace.of(onPace, measuredAt = measured, now = nextPoll)) + assertEquals(50.0, atPoll.elapsedPercent, 1e-9) + assertEquals(Pace.ON_PACE, atPoll.pace, "judged at the draw time instead, 49 vs 55 would read slower") + assertEquals(Pace.SLOWER, fresh(onPace, nextPoll)!!.pace, "the same number freshly measured then IS slower") + + // A fast burn stays "faster" for as long as the snapshot is drawn, fossil mirror included. + val burning = limit(used = 70.0, resetsAt = resetsAt) + for (age in listOf(0L, 15L, 60L, 149L)) { + val r = assertNotNull(UsagePace.of(burning, measuredAt = measured, now = measured + age * minute), "age $age") + assertEquals(Pace.FASTER, r.pace, "age $age min") + assertEquals(50.0, r.elapsedPercent, 1e-9, "age $age min") + } + // ...until the window it describes has reset. + assertNull(UsagePace.of(burning, measuredAt = measured, now = resetsAt)) + } + + @Test + fun `a measurement time that is unknown, hostile or ahead of the phone falls back to now`() { + assertEquals(now - minute, UsagePace.measurementTime(now - minute, now)) + assertEquals(now, UsagePace.measurementTime(now, now)) + assertEquals(now, UsagePace.measurementTime(0, now), "0 is the parser's default for a missing updatedAt") + assertEquals(now, UsagePace.measurementTime(-1, now)) + assertEquals(now, UsagePace.measurementTime(Long.MIN_VALUE, now)) + assertEquals(now, UsagePace.measurementTime(now + minute, now), "a desktop clock ahead of the phone's") + assertEquals(now, UsagePace.measurementTime(Long.MAX_VALUE, now)) + val l = limit(used = 49.0) // 150 of 300 minutes left at `now` + for (at in listOf(0L, -1L, now + 10 * minute)) { + assertEquals(fresh(l), UsagePace.of(l, measuredAt = at, now = now), "measuredAt $at") + } + } + + @Test + fun `a measurement from before the window began gets no verdict`() { + // 350 minutes between the measurement and the reset: longer than the 5h window, so either + // the length is wrong for this limit or the timestamp is not this window's. + assertNull(UsagePace.of(limit(resetsAt = now + 150 * minute), measuredAt = now - 200 * minute, now = now)) + // Exactly the whole window before the reset is the window's first instant. + val start = assertNotNull(UsagePace.of(limit(used = 0.0, resetsAt = now + 150 * minute), measuredAt = now - 150 * minute, now = now)) + assertEquals(0.0, start.elapsedPercent, 1e-9) + } + + @Test + fun `hostile values from the hand-editable mirror are clamped or refused, never wrapped`() { + assertEquals(100.0, fresh(limit(used = 250.0))!!.usedPercent) + assertEquals(0.0, fresh(limit(used = -20.0))!!.usedPercent) + assertNull(fresh(limit(used = Double.NaN))) + // Long arithmetic would wrap `resetsAt - now` and `minutes * 60000` to plausible values. + assertNull(fresh(limit(resetsAt = Long.MIN_VALUE))) + assertNull(fresh(limit(resetsAt = Long.MAX_VALUE))) + val huge = assertNotNull(fresh(limit(resetsAt = now + minute, windowMinutes = Long.MAX_VALUE))) + assertEquals(Pace.SLOWER, huge.pace, "one minute left of an enormous window: nearly all of it has elapsed") + } + + @Test + fun `window labels are derived from minutes`() { + assertEquals("5h", UsagePace.windowLabel(300)) + assertEquals("7d", UsagePace.windowLabel(10_080)) + assertEquals("1d", UsagePace.windowLabel(1_440)) + assertEquals("90m", UsagePace.windowLabel(90)) + } + + @Test + fun `a mirror limit without windowMinutes still gets a pace line, measured at its account's updatedAt`() { + // The desktop's buildMirrorUsage writes `windowMinutes: null` for every Claude limit + // (agent-status-mirror.test.ts), which is why the kind defaults exist at all, and stamps each + // account with the time its numbers were fetched. + val resetsAt = now + 60 * minute + val fetchedAt = now - 30 * minute + val snap = ProjectsParser.parseBlob( + "--NT-STATUS-SPLIT--\n" + + """{"v":1,"updatedAt":$fetchedAt,"nodes":{},"usage":{"updatedAt":$fetchedAt,"accounts":[{"accountId":null,"label":null,"email":null,"agentId":"claude","status":"ok","updatedAt":$fetchedAt,"limits":[{"kind":"session","group":"session","usedPercent":95,"severity":null,"resetsAt":$resetsAt,"windowMinutes":null,"scopeLabel":null,"isActive":true}]}]}}""" + ) + val account = snap.status!!.usage!!.accounts.single() + val l = account.limits.single() + assertNull(l.windowMinutes) + val r = assertNotNull(UsagePace.of(l, measuredAt = account.updatedAt, now = now)) + // 90 of 300 minutes were left when it was fetched: 70% elapsed then (80% by now). + assertEquals(70.0, r.elapsedPercent, 1e-9) + assertEquals("5h usage pace faster", r.line) + } + + @Test + fun `context fill is clamped, rounded like the desktop, and banded like its meter`() { + assertNull(ContextFill.label(null)) + assertNull(ContextFill.label(Double.NaN)) + assertEquals("42% context", ContextFill.label(41.6)) + assertEquals("42% context", ContextFill.label(42.4)) + assertEquals("43% context", ContextFill.label(42.5), "half rounds up, as Math.round") + assertEquals("100% context", ContextFill.label(140.0)) + assertEquals("0% context", ContextFill.label(-3.0)) + assertEquals(ContextFill.Level.OK, ContextFill.level(59.9)) + assertEquals(ContextFill.Level.HIGH, ContextFill.level(60.0)) + assertEquals(ContextFill.Level.HIGH, ContextFill.level(85.0)) + assertEquals(ContextFill.Level.CRITICAL, ContextFill.level(85.1)) + } + + @Test + fun `the context fill an event card shows is its node's, read from the parsed inbox`() { + val snap = ProjectsParser.parseBlob( + "--NT-STATUS-SPLIT--\n" + + """{"v":1,"updatedAt":1,"nodes":{},"inbox":{"events":[{"id":"e1","ts":1,"nodeId":"n1","kind":"approval","title":"Approve"},{"id":"e2","ts":2,"nodeId":"n2","kind":"done","title":"Finished"}],"nodes":{"n1":{"contextPercent":73.4,"updatedAt":1}}}}""" + ) + val inbox = snap.status!!.inbox!! + val byEvent = inbox.events.associate { it.id to ContextFill.label(inbox.nodes[it.nodeId]?.contextPercent) } + assertEquals(mapOf("e1" to "73% context", "e2" to null), byEvent) + } +} diff --git a/android/protocol/src/test/kotlin/dev/nodeterm/protocol/WorkflowPathFilterTest.kt b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/WorkflowPathFilterTest.kt new file mode 100644 index 0000000000..3d24b75f24 --- /dev/null +++ b/android/protocol/src/test/kotlin/dev/nodeterm/protocol/WorkflowPathFilterTest.kt @@ -0,0 +1,487 @@ +package dev.nodeterm.protocol + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Audit A63: `.github/workflows/android.yml` runs only when a changed file matches the `paths` filter + * of its `push` or `pull_request` trigger. The interop tests run the desktop's own code, bundled from + * `host-fixture.ts`, so a change to any file in that bundle changes what they test. The first filter + * covered `android/`, `src/main/remote/` and the `src/main/pairing-*.ts` files only: 35 of the 49 desktop + * sources in the bundle (all of src/core and src/shared, and `src/main/windows-ssh-keys.ts`) could + * change with no Android run at all. + * + * This reads the inputs of the very bundle the interop tests run (the bundler writes esbuild's + * metafile beside it) and checks that both filters match each one, plus the files the workflow uses + * without bundling them. It holds by induction: a change that adds a file to the bundle has to edit a + * file already in it (an import), the bundler's aliases (under android/) or tsconfig.json, so it runs + * the workflow, and this test then fails until the new path is listed. + * + * The second test does the same for the repo files the protocol tests read, found by [repoReads]; a + * read it cannot resolve to a path has to be explained in `computedReads`. + * + * The filter semantics are GitHub's (`*` stays within one path segment, `**` crosses them); syntax this + * reader does not implement is refused rather than guessed. + */ +class WorkflowPathFilterTest { + private val root = InteropHarness.repoRoot + private val workflowPath = ".github/workflows/android.yml" + private val workflow by lazy { File(root, workflowPath).readText().replace("\r\n", "\n") } + private val triggers = listOf("push", "pull_request") + + /** + * Files the workflow depends on that are not in the bundle's metafile, with why: + * - tsconfig.json: esbuild applies the root tsconfig's compilerOptions to every file it bundles + * (it does not follow the references, so tsconfig.node.json is not read); + * - package.json, package-lock.json: `npm ci` installs esbuild, ws and tweetnacl from them, and + * they stand in for every node_modules input of the bundle; + * - the workflow file itself. + */ + private val unbundledInputs = listOf("tsconfig.json", "package.json", "package-lock.json", "src/server/index.ts", workflowPath) + + /** + * Test files that read a repo file through a computed path, which [repoReads] cannot resolve, with + * why the paths they read run the workflow anyway. Any other computed read fails the test: it is how + * ContributorDocsTest came to read CONTRIBUTING.md with that file missing from the filter (review + * of A61/A71), because it read its docs as `File(root, path)` over a list of strings. + */ + private val computedReads = mapOf( + "WorkflowPathFilterTest.kt" to "the bundle's metafile inputs and the paths this test requires, which it checks itself", + "GradleCiCoverageTest.kt" to "the gradle directories .github/dependabot.yml names (in the filter); each must " + + "hold a Gradle build, and this repo's are all under android/", + "InteropHarnessTest.kt" to "the bundler script InteropHarness.bundleCommand names, under android/", + ) + + @Test + fun `every file the fixture bundle is built from runs the workflow`() { + assumeTrue(InteropHarness.available(), "node + repo node_modules (npm ci) are needed for interop tests") + // Reading it builds the bundle first; the bundler writes the metafile beside it. + val meta = Json.parseToJsonElement(InteropHarness.bundleMeta.readText()).jsonObject + val inputs = meta.getValue("inputs").jsonObject.keys + assertTrue(inputs.any { it.startsWith("src/main/remote/") }, "the metafile names no desktop source: $inputs") + val required = inputs.map { input -> + if (input.startsWith("node_modules/")) { + "package-lock.json" + } else { + assertTrue(File(root, input).isFile, "metafile input $input is not a repo file") + input + } + }.toSortedSet() + assertCovered(required, "bundled into host-fixture.ts") + } + + @Test + fun `every file the ack consumer fixture is built from runs the workflow`() { + assumeTrue(InteropHarness.available("ack-sweep"), "node + esbuild are needed for ack interop tests") + val meta = Json.parseToJsonElement(InteropHarness.ackBundleMeta.readText()).jsonObject + val inputs = meta.getValue("inputs").jsonObject.keys + assertTrue("src/core/ack-sweep.ts" in inputs, "the fixture does not run the actual ack consumer") + val required = inputs.map { if (it.startsWith("node_modules/")) "package-lock.json" else it }.toSortedSet() + assertCovered(required, "bundled into ack-fixture-runner.ts") + } + + @Test + fun `every actual SSH actions producer file runs the workflow`() { + assumeTrue(InteropHarness.available("ssh-actions"), "node + esbuild are needed for SSH actions interop") + val meta = Json.parseToJsonElement(InteropHarness.sshActionsBundleMeta.readText()).jsonObject + val inputs = meta.getValue("inputs").jsonObject.keys + assertTrue("src/core/ssh-actions.ts" in inputs, "fixture must run the actual selected-profile service") + assertTrue("src/core/workspace-store.ts" in inputs, "fixture must run the actual save queue") + val required = inputs.map { if (it.startsWith("node_modules/")) "package-lock.json" else it }.toSortedSet() + assertCovered(required, "bundled into ssh-actions-fixture.ts") + } + + @Test + fun `the actual relay advertisement writer and remover run the workflow`() { + assumeTrue(InteropHarness.available("relay-advertisement"), "node + esbuild are needed for advertisement interop") + val inputs = Json.parseToJsonElement(InteropHarness.relayAdvertisementBundleMeta.readText()).jsonObject + .getValue("inputs").jsonObject.keys + for (required in listOf("src/main/remote/relay-advertise.ts", "src/core/fs-atomic.ts")) { + assertTrue(required in inputs, "fixture must execute actual producer $required") + } + assertCovered(inputs.map { if (it.startsWith("node_modules/")) "package-lock.json" else it }.toSortedSet(), + "bundled into relay-advertisement-fixture.ts") + } + + @Test + fun `every actual managed creation producer file runs the workflow`() { + assumeTrue(InteropHarness.available("managed-session"), "node + esbuild are needed for managed creation interop") + val inputs = Json.parseToJsonElement(InteropHarness.managedSessionBundleMeta.readText()).jsonObject + .getValue("inputs").jsonObject.keys + for (required in listOf("src/core/managed-terminal-plan.ts", "src/core/managed-terminals.ts", + "src/core/pty-manager.ts", "src/core/workspace-store.ts", "src/core/ssh-actions.ts")) { + assertTrue(required in inputs, "fixture must execute actual producer $required") + } + assertTrue("android/protocol/src/test/interop/managed-native.ts" in inputs, + "native recorder boundary must be explicit, rather than a saved receipt fixture") + assertCovered(inputs.map { if (it.startsWith("node_modules/")) "package-lock.json" else it }.toSortedSet(), + "bundled into managed-session-fixture.ts") + } + + @Test + fun `every actual custom Server profile producer file runs the workflow and invalidates Gradle tests`() { + assumeTrue(InteropHarness.available("server-profile"), "node + esbuild are needed for Server profile interop") + val inputs = Json.parseToJsonElement(InteropHarness.serverProfileBundleMeta.readText()).jsonObject + .getValue("inputs").jsonObject.keys + for (required in listOf("src/server/config.ts", "src/server/platform-server.ts", + "src/core/workspace-store.ts", "src/core/agent-status-mirror.ts", "src/core/ssh-actions.ts", + "src/core/managed-terminals.ts", "android/protocol/src/test/interop/server-profile-os.ts")) { + assertTrue(required in inputs, "fixture must execute actual producer $required") + } + assertFalse("src/server/index.ts" in inputs, + "this component fixture must not start the full Server boot/hook lifecycle") + assertCovered(inputs.map { if (it.startsWith("node_modules/")) "package-lock.json" else it }.toSortedSet(), + "bundled into server-profile-fixture.ts") + + val build = File(root, "android/protocol/build.gradle.kts").readText() + .replace(Regex("//[^\n]*"), "") + val directoryLoop = Regex("""for\s*\(dir\s+in\s+listOf\(([^)]*)\)\)\s*\{\s*inputs\.dir\(rootDir\.resolve\("\.\./\.\./\${'$'}dir"\)\)\.withPathSensitivity\(PathSensitivity\.RELATIVE\)\s*}""") + .find(build) ?: fail("the protocol test task must declare its actual desktop source directories as Gradle inputs") + val directories = Regex("\"([^\"]+)\"").findAll(directoryLoop.groupValues[1]).map { it.groupValues[1] }.toSet() + val files = Regex("""inputs\.file\(rootDir\.resolve\("\.\./\.\./([^"${'$'}]+)"\)\)\.withPathSensitivity\(PathSensitivity\.RELATIVE\)""") + .findAll(build).map { it.groupValues[1] }.toSet() + val missed = inputs.filter { it.startsWith("src/") && it !in files && directories.none { dir -> it.startsWith("$dir/") } } + assertTrue(missed.isEmpty(), "these actual Server producer inputs leave local Gradle tests up to date: $missed") + } + + @Test + fun `managed creation producer inputs also invalidate Gradle protocol tests`() { + assumeTrue(InteropHarness.available("managed-session"), "node + esbuild are needed for managed creation interop") + val inputs = Json.parseToJsonElement(InteropHarness.managedSessionBundleMeta.readText()).jsonObject + .getValue("inputs").jsonObject.keys.filter { it.startsWith("src/") } + val build = File(root, "android/protocol/build.gradle.kts").readText() + .replace(Regex("//[^\n]*"), "") + val directoryLoop = Regex("""for\s*\(dir\s+in\s+listOf\(([^)]*)\)\)\s*\{\s*inputs\.dir\(rootDir\.resolve\("\.\./\.\./\${'$'}dir"\)\)\.withPathSensitivity\(PathSensitivity\.RELATIVE\)\s*}""") + .find(build) ?: fail("the protocol test task must declare its actual desktop source directories as Gradle inputs") + val directories = Regex("\"([^\"]+)\"").findAll(directoryLoop.groupValues[1]).map { it.groupValues[1] }.toSet() + val missed = inputs.filter { input -> directories.none { input.startsWith(it + "/") } } + assertTrue(missed.isEmpty(), "these actual managed producer inputs leave local Gradle tests up to date: $missed") + } + + @Test + fun `the files the workflow uses without bundling them run it`() { + // Repo files the protocol tests read (ResumeOfferTest reads a src/shared file, ContributorDocsTest + // reads CONTRIBUTING.md): each read by a literal path must run the workflow, and a read by a + // computed path must be explained in computedReads. + val testSources = File(root, "android/protocol/src/test/kotlin") + val reads = testSources.walkTopDown().filter { it.extension == "kt" }.flatMap { file -> + repoReads(file.readText()).map { file.name to it } + }.toList() + val literalReads = reads.mapNotNull { it.second }.toSortedSet() + assertTrue(literalReads.isNotEmpty(), "found no repo file read by a literal path in $testSources") + val computed = reads.filter { it.second == null }.map { it.first }.toSortedSet() + val unexplained = computed - computedReads.keys + assertTrue( + unexplained.isEmpty(), + "these test files read a repo file through a computed path, so this test cannot check that editing " + + "the file runs the workflow: $unexplained. Name each file by a literal path from " + + "InteropHarness.repoRoot (or from a val bound to it), or list the test file in computedReads " + + "with why its paths are in the filter anyway." + ) + val stale = computedReads.keys - computed + assertTrue(stale.isEmpty(), "computedReads lists $stale, which read no repo file through a computed path now; remove it") + val required = (unbundledInputs + literalReads).toSortedSet() + for (path in required) assertTrue(File(root, path).exists(), "$path does not exist; update this test") + assertCovered(required, "read by the workflow or its tests") + } + + @Test + fun `the read scanner sees literal, aliased and computed reads`() { + // Built from a template so that this file's own source names no repo file by a literal path. + val h = "InteropHarness" + ".repoRoot" + val source = """ + /** File($h, "in/a/kdoc.md") is not a read. */ + class X { + // File($h, "in/a/comment.md") is not either + private val base = $h + var dir: File = $h + private val other = File("/tmp") + val a = File($h, "CONTRIBUTING.md") + val b = File( base , "docs/android.md" ) + val c = base.resolve("android/README.md") + val d = File(dir, "src/shared/types.ts") + val e = $h.resolve("docs/SERVER.md") + val f = File(base, path) + val g = File(base, "docs/" + name) + val i = File($h, "x/${'$'}name.md") + val j = File(other, "not/the/repo.md") + val k = other.base.resolve("not/an/alias.md") + } + """.trimIndent() + assertEquals( + listOf("CONTRIBUTING.md", "docs/android.md", "android/README.md", "src/shared/types.ts", "docs/SERVER.md", null, null, null), + repoReads(source) + ) + } + + @Test + fun `the glob matcher follows GitHub's path filter syntax`() { + assertTrue(globRegex("android/**").matches("android/protocol/src/test/interop/host-fixture.ts")) + assertTrue(globRegex("src/main/*.ts").matches("src/main/windows-ssh-keys.ts")) + assertFalse(globRegex("src/main/*.ts").matches("src/main/remote/host-service.ts"), "* must not cross /") + assertFalse(globRegex("src/main/pairing-*.ts").matches("src/main/windows-ssh-keys.ts")) + assertFalse(globRegex("tsconfig.json").matches("tsconfigXjson"), ". is literal") + assertFalse(globRegex("tsconfig.json").matches("sub/tsconfig.json"), "a pattern is anchored") + for (bad in listOf("src/**/*.ts?", "src/[ab].ts", "!src/**", "src/+.ts", "src/{a,b}.ts")) { + assertFailsWith(bad) { globRegex(bad) } + } + } + + @Test + fun `the filter reader finds each trigger's paths and nothing else`() { + val yaml = """ + name: x + # a comment + on: + push: + branches: [main] + paths: + # a comment inside the list + - 'a/**' + + - "b/*.ts" + - c.json + pull_request: + workflow_dispatch: + jobs: + paths: + - 'not/a/trigger' + """.trimIndent() + assertEquals(listOf("a/**", "b/*.ts", "c.json"), pathFilter(yaml, "push")) + assertNull(pathFilter(yaml, "pull_request"), "a trigger without paths runs on every change") + assertFailsWith { pathFilter(yaml, "merge_group") } + val ignoring = "on:\n push:\n paths-ignore:\n - 'docs/**'\n" + assertFailsWith { pathFilter(ignoring, "push") } + } + + @Test + fun `Android workflow pushes only main and retains PR and manual entry points`() { + androidTriggerPolicy(workflow) + } + + @Test + fun `the trigger policy reads ref filters as data and rejects widened entry points`() { + val yaml = """ + name: x + on: + # merge_group and branches: [feature] in comments are not triggers or filters. + push: + branches: ["main"] + paths: + - 'android/**' + pull_request: + paths: + - 'android/**' + workflow_dispatch: + inputs: + merge_group: + description: A nested input name is not a workflow trigger. + jobs: + nested: + on: + push: + branches: [feature] + """.trimIndent() + androidTriggerPolicy(yaml) + androidTriggerPolicy(yaml.replace("branches: [\"main\"]", "branches:\n - 'main'")) + val mutations = mapOf( + "unrestricted pushes" to yaml.replace(" branches: [\"main\"]\n", ""), + "additional branch" to yaml.replace("[\"main\"]", "[main, feature]"), + "branch wildcard" to yaml.replace("[\"main\"]", "['main*']"), + "other branch" to yaml.replace("[\"main\"]", "[feature]"), + "tag pushes" to yaml.replace(" branches: [\"main\"]", " branches: [main]\n tags: ['v*']"), + "missing PR" to yaml.replace(" pull_request:", " pull_request_target:"), + "merge queue" to yaml.replace(" workflow_dispatch:", " merge_group:\n workflow_dispatch:"), + "missing manual entry" to yaml.replace(" workflow_dispatch:", " schedule:"), + ) + for ((name, changed) in mutations) { + assertFalse(changed == yaml, "$name must change the actual configuration data") + assertFailsWith(name) { androidTriggerPolicy(changed) } + } + } + + private fun assertCovered(required: Set, what: String) { + for (trigger in triggers) { + val patterns = pathFilter(workflow, trigger) ?: continue // no filter: every change runs it + val regexes = patterns.map { globRegex(it) } + val missed = required.filter { path -> regexes.none { it.matches(path) } } + assertTrue( + missed.isEmpty(), + "$workflowPath: the $trigger paths filter does not match these files $what, so a change to " + + "one runs no Android test (audit A63). Add them (or a glob) to the push and pull_request " + + "paths and to the comment above them:\n ${missed.joinToString("\n ")}" + ) + } + } + + companion object { + /** + * The repo files [source] (a Kotlin test file) reads, one entry per read in source order: the path + * when it is a string literal, null when it is computed. A read is `File(, …)` or + * `.resolve(…)`, where is `InteropHarness.repoRoot` or a `val`/`var` the same file binds + * to it. Whole-line and block comments are skipped. The scan is textual: a root handed in through a + * parameter is not seen, and a string that quotes a read counts as a computed one (the safe side). + */ + internal fun repoReads(source: String): List { + val code = source.replace(Regex("""/\*.*?\*/""", RegexOption.DOT_MATCHES_ALL), " ") + .lines().filterNot { it.trimStart().startsWith("//") }.joinToString("\n") + val aliases = Regex("""\b(?:val|var)\s+(\w+)\s*(?::\s*File\s*)?=\s*InteropHarness\.repoRoot(?![\w.])""") + .findAll(code).map { Regex.escape(it.groupValues[1]) } + val root = (sequenceOf("""InteropHarness\.repoRoot""") + aliases).joinToString("|") + val read = Regex("""(?:\bFile\(\s*(?:$root)\s*,|(?? { + fun indent(line: String) = line.length - line.trimStart().length + fun skip(line: String) = line.isBlank() || line.trimStart().startsWith("#") + val lines = yaml.lines() + val on = lines.indexOfFirst { it.trimEnd() == "on:" } + if (on < 0) throw AssertionError("no top-level `on:` block") + var i = on + 1 + var triggerIndent = -1 + while (i < lines.size) { + val line = lines[i] + if (!skip(line)) { + if (indent(line) == 0) break + if (line.trim() == "$trigger:") { + triggerIndent = indent(line) + break + } + } + i++ + } + if (triggerIndent < 0) throw AssertionError("no `$trigger:` trigger under `on:`") + var paths: MutableList? = null + var pathsIndent = -1 + i++ + while (i < lines.size) { + val line = lines[i] + i++ + if (skip(line)) continue + val ind = indent(line) + if (ind <= triggerIndent) break + val text = line.trim() + if (paths != null && ind > pathsIndent) { + if (!text.startsWith("- ")) throw AssertionError("unexpected line in the $trigger paths list: $line") + paths.add(text.removePrefix("- ").trim().removeSurrounding("'").removeSurrounding("\"")) + continue + } + if (text.startsWith("paths-ignore:")) throw AssertionError("paths-ignore is not understood by this reader") + if (text == "paths:") { + paths = ArrayList() + pathsIndent = ind + } else if (text.startsWith("paths:")) { + throw AssertionError("only a block `paths:` list is understood: $line") + } + } + return paths + } + + /** A68: inspect trigger data, separately from A63's path coverage. */ + internal fun androidTriggerPolicy(yaml: String) { + fun indent(line: String) = line.length - line.trimStart().length + fun skip(line: String) = line.isBlank() || line.trimStart().startsWith("#") + val lines = yaml.lines() + val on = lines.indices.filter { lines[it].trimEnd() == "on:" } + assertEquals(1, on.size, "expected one top-level block-style on declaration") + val events = linkedMapOf>() + var current: MutableList? = null + for (line in lines.drop(on.single() + 1)) { + if (skip(line)) continue + val depth = indent(line) + if (depth == 0) break + if (depth == 2) { + val event = Regex("([a-z_]+):").matchEntire(line.trim())?.groupValues?.get(1) + ?: fail("unsupported workflow trigger declaration: $line") + assertFalse(event in events, "duplicate trigger $event") + current = mutableListOf().also { events[event] = it } + } else { + assertTrue(depth > 2 && current != null, "invalid trigger indentation: $line") + current!!.add(line) + } + } + assertEquals(setOf("push", "pull_request", "workflow_dispatch"), events.keys, + "retain PR and manual dispatch, and do not add merge_group or another event") + + fun fields(event: String): Map { + val result = linkedMapOf() + for (line in events.getValue(event).filter { indent(it) == 4 }) { + val field = Regex("([a-z_][a-z_-]*):\\s*(.*)").matchEntire(line.trim()) + ?: fail("unsupported $event field: $line") + val key = field.groupValues[1] + assertFalse(key in result, "duplicate $event field $key") + result[key] = field.groupValues[2] + } + return result + } + val push = fields("push") + assertEquals(setOf("branches", "paths"), push.keys, + "push must select branches and paths, with no tag or ignore filters") + assertEquals(setOf("paths"), fields("pull_request").keys, + "retain the PR path filter without introducing a target-branch restriction") + + fun literal(raw: String): String { + val trimmed = raw.trim() + val value = if (trimmed.length >= 2 && trimmed.first() in listOf('\'', '"') && + trimmed.last() == trimmed.first()) trimmed.substring(1, trimmed.length - 1) else trimmed + assertTrue(Regex("[A-Za-z0-9_./*?!+-]+").matches(value), "unsupported branch pattern $raw") + return value + } + val rawBranches = push.getValue("branches") + val branches = if (rawBranches.startsWith("[") && rawBranches.endsWith("]")) { + val contents = rawBranches.substring(1, rawBranches.length - 1) + if (contents.isBlank()) emptyList() else contents.split(',').map(::literal) + } else { + assertTrue(rawBranches.isEmpty(), "branches must be a literal inline or block list") + val block = events.getValue("push") + val at = block.indexOfFirst { indent(it) == 4 && it.trim() == "branches:" } + assertTrue(at >= 0, "no branches list") + block.drop(at + 1).takeWhile { indent(it) > 4 }.map { line -> + assertTrue(indent(line) == 6 && line.trimStart().startsWith("- "), + "unsupported branch list entry: $line") + literal(line.trim().removePrefix("- ")) + } + } + assertEquals(listOf("main"), branches, "push must admit only main, not feature branches or tags") + val paths = pathFilter(yaml, "push") ?: fail("push must retain its path filter") + assertTrue(paths.isNotEmpty(), "push path filter must not disable all changed-file runs") + assertEquals(paths, pathFilter(yaml, "pull_request"), "push and PR must retain the same input coverage") + } + + /** A GitHub Actions path pattern as a regex over a repo-relative, `/`-separated path. */ + internal fun globRegex(pattern: String): Regex { + val unsupported = pattern.firstOrNull { it in "?+[]!{}" } + require(unsupported == null) { "path filter syntax '$unsupported' in $pattern is not implemented here" } + val out = StringBuilder() + var i = 0 + while (i < pattern.length) { + if (pattern.startsWith("**", i)) { + out.append(".*") + i += 2 + } else if (pattern[i] == '*') { + out.append("[^/]*") + i++ + } else { + out.append(Regex.escape(pattern[i].toString())) + i++ + } + } + return Regex(out.toString()) + } + } +} diff --git a/android/protocol/src/test/resources/bsd-script-standin.sh b/android/protocol/src/test/resources/bsd-script-standin.sh new file mode 100644 index 0000000000..4542196655 --- /dev/null +++ b/android/protocol/src/test/resources/bsd-script-standin.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# A stand-in for BSD script(1), for SshTestHostTest on a machine that only has util-linux's. +# It follows BSD's argument grammar, `script [-aeFkqr] [-t time] [file [command ...]]`: no -c and no +# --version (an unknown option prints the usage and exits 1), and the command is the argv after the +# typescript file. The pty itself is borrowed from util-linux, so that argv is quoted back into one line. +while getopts aeFkqrt: o; do + if [ "$o" = '?' ]; then + echo 'usage: script [-aeFkqr] [-t time] [file [command ...]]' >&2 + exit 1 + fi +done +shift $((OPTIND - 1)) +file=$1 +shift +line='' +for a in "$@"; do + line="$line '$(printf '%s' "$a" | sed "s/'/'\\\\''/g")'" +done +exec script -qec "$line" "$file" diff --git a/android/protocol/src/test/resources/crypto-vectors.json b/android/protocol/src/test/resources/crypto-vectors.json new file mode 100644 index 0000000000..a7e468ed24 --- /dev/null +++ b/android/protocol/src/test/resources/crypto-vectors.json @@ -0,0 +1,198 @@ +{ + "generatedBy": "android/protocol/scripts/gen-crypto-vectors.cjs", + "tweetnacl": "1.0.3", + "keypairs": [ + { + "secretKey": "tn+dwcGawdKiz0NJwOhU2s41rukSt8SLQl2F/Sr7FN8=", + "publicKey": "nIYiolgjxDE2y0JyVXQG4HRLZu7f1lpOn0C1FGK2Sm0=" + }, + { + "secretKey": "KstxP6ASS63sn4f6RFOq48JboKaciG25++5n+2Eh4PE=", + "publicKey": "A23MIH+JtHGuVHQY7S5E37eCeEVvd2jTrSU4ftZuWVg=" + }, + { + "secretKey": "PfThXUZyqIn9XNrBSVvjgM0KrUqzZ8SHZXwK/BeVPps=", + "publicKey": "bd7yi6jKZlIIoCsoEd8Sgo1g4intSDdYdEKWOdYdXQY=" + }, + { + "secretKey": "64yIO6Yo0ZR/X232iL6Op5ttKLZ8p2U3gCEfrUm2m+w=", + "publicKey": "VuXyx99vMR22rHdUW8PFLypPYxVWC243fQcWFH9xBHc=" + }, + { + "secretKey": "uXp3/SU+hwc47aQfv6zVq7jvo04WBDPlTvJim8Nq7VU=", + "publicKey": "2PR8TwWw3xk2sRu2Mxp0AeF/pSyxJ995BJjI4Fb4fUY=" + } + ], + "before": [ + { + "aSecret": "dGUVr8QgXI1O2D0nATa3BxhyHRqnWlH9BTz3mTCG++k=", + "bPublic": "hg1WO7RcrZG6f8VUM8+oIHqxsn7q2x6pbiJQw78ZwzQ=", + "shared": "55X+ZDDPJHkqYxPEpDQ3jy/lN2df1kgchP9J5W1Nvpw=", + "sas": "679 843" + }, + { + "aSecret": "9w/jJ1BMb2WnvVy5Gti/V9sDtQ5EuN0wNNpjd92ugsM=", + "bPublic": "QH42fRFBsAp3fMQfbrN4esJFa6u+T0pKkyyx6PgjuCk=", + "shared": "KDOEbdcGhJNoZI0Oq5jXOpAvTzJT4wDw5aGNqbSI9uM=", + "sas": "667 384" + }, + { + "aSecret": "JMR6F+H6BpurObWCK4liEu9rC8aSDd33BMgspPosdzY=", + "bPublic": "5dvBQ7DuxRvn02EXwOgZ/7QZOlJEvrHmXu96dXkSmD4=", + "shared": "4y8T4LAYzuViyc/UEY/OJmICuZS9XYGnpeYZ63CXILY=", + "sas": "235 817" + }, + { + "aSecret": "d39z6bmav2NwbgFLCCvoBQ7/uQdPVBWTdT34YtfKTKc=", + "bPublic": "Iyuub8hqIFwu8lD832EquIT70ELBLVOcTvZaLoQHDTU=", + "shared": "QQ+aJsK3USkaPfAFWBRdDcXS32y2l5E/CgUva4m6DCk=", + "sas": "775 723" + } + ], + "secretbox": [ + { + "key": "7BXHz4L2OInyWMeEskFifaUD2z56LNc1qbrJHLVT/kA=", + "nonce": "8mqmyZC0NJmtInpFZFZh1y7iWmr6zH74", + "msg": "", + "box": "8KnEs/m/1DUx6ejSGrXVgg==" + }, + { + "key": "XxiTmij/YNyaWvLTF4kUJEFp9w6f94NXREVmnjA9BPU=", + "nonce": "V63mq9EG9QtYHUklUCWJsU2r8bxCuHne", + "msg": "hg==", + "box": "C0IgrbVFAI8McoU2d+eKc+s=" + }, + { + "key": "hwzkFWIKC/umdz0/S/Fwo1Nu0GOjXHVhgszgl2OJVXc=", + "nonce": "Bc8kaxJ1Wj3YYrULBc+wgbUcfJCgO9Xj", + "msg": "W7UpfS4+17xGNbGsCtMq", + "box": "6cI9UcoOxwARqy4F6zJAI7KoTaMn5d+njQLGmh7aGA==" + }, + { + "key": "KUiczC6QT9GxXAYI2OygyHSqE2cxGjukPojeV+Pltgc=", + "nonce": "GcZzweHLfHaYhidyPrpYxcJkjh44KqDz", + "msg": "8bpnLM+AO5Q2Hth19zAsrQ==", + "box": "1vO4JxX85ufI4KtIMxd770cLc8t7IVs3z3aTTMJZ1X8=" + }, + { + "key": "LkYkPB+4NBPb4m2ck7uMJ7OeG9+TrbnSw+WXcAxEzyY=", + "nonce": "m9zZDRCEkey7Opdw8SnOLTo/sPVGtFXX", + "msg": "f17kQ8UtEYc7MWWg51J3y+I=", + "box": "EnZ9ZuHM36IF9q3RUNj1tshXogrL2W5OrzOY8eX7TFv4" + }, + { + "key": "FOWhP+AzkyJYEeOMTEOrUKRMIt+T0XTG2vTZsmfZbYY=", + "nonce": "f6GGaG/hxFUwPbaBuAZPF4BTku5N7yFi", + "msg": "D5gLEYnxu+wK1JFleY9iOcKxyrZwZEHTn0vQr5QyQQ==", + "box": "/RHyrVhA9lSSKtkTHeooLpyM8wBCgHJPvYbdlVcNwIy/1De4vlt4L834f+dm6aM=" + }, + { + "key": "xVY6wKvSBsyuklEyBRkSjdOgQx57H1sHrfOuGqywU3A=", + "nonce": "cYMJjm6cPCM6422tUlAj1A6r1zUHf67G", + "msg": "h36HmizB6K3neaENiaxB5+XQUL08r4tzQOFETxbJZMw=", + "box": "bBddrNtxJHOEB3LTL7q4zKFKaAPYECgX3/vYpd4X9g/VSIs8MHQeGrPMSgnI04zP" + }, + { + "key": "tTx6QcSJ7TU0WURS0ZQ11ldeW1dCbFaJF0HYmaOdlGU=", + "nonce": "5r3aD5ldME0T3/joQlJpmC04yncD/31R", + "msg": "NdE1Y1wXiAHZPzdW+AlwuYB2KAlqDz/Qn8r0wGyW+JMd", + "box": "zkYDb1vhrTJ9sl/FehLFOJ7OeUnHGc970Bs818Q+eC8D2mWCKFG58t9GqmUaSXFL+Q==" + }, + { + "key": "8PvkCCyyZ7adUiBQFuMKP5lL+/0ZAC7/3wimYo+eZMQ=", + "nonce": "0zE3scspmkTggfddaBWUO4fS5GQro2Dp", + "msg": "4Y4XtZTEpwQVttRg+WMahztZi14ZR/TLFNIZuxnZ6fpfpDB2DjILFYer9hHgyUDGUNlkIL9C0UEvEFDIJGGp", + "box": "yPJKDHQVNjCK7+R6MBz62gPTG1NLVdbUanD5D9AH4tiy0K1lFXrnos1nErGCSO836r/BbunIBiKP38ZtMgkWYGbNoolQsnzE4kyXNTQanA==" + }, + { + "key": "tdvOKnOBtUUv6DlPNE91DVfxIRHPZNRYgVfUbuPRVls=", + "nonce": "vly2UELCU0TW58e+LsEOth9NNSZIQzK2", + "msg": "QHAlFUVSHEF89mqwyX8H5UBsFy1iTakyQ2sX7biVqWyeTtlLICNVBCFNwgXAzNEJ/5dD/j89fJKqrVBWD/qGvA==", + "box": "prY+HQZLigvgGyn3rQVg35NXvlddcrwuq69h0myYuaQo8O3uFVGap0Rj1kY/0ucSVpU81DLA4XABdlvpv1AVh/QdrKquJjZBlO191TuAkp0=" + }, + { + "key": "8aobAK7C5++1nKefhUUORn/GKBf6yk6BFWU6qO48Rs0=", + "nonce": "P280mI/sd+CY+M9gsZ7+h0J+PwOe09s1", + "msg": "x7/ttQoFJ0bAIqUg3TOxCEWE/1yuYsnaJSur8pt05qhDjvsRBko+c07JyYg4eqzTWMwmxiQEcc3H28ia2USpBOc=", + "box": "o01Qgr0PHfddbQuY8qfFa0Zv+dlnx37kfPmSViFKMZYDEcjzBHC1Dpye7EeSvmA53zlBFUDDvDP6K4PLpvxu2tPumhGrq5jtS0xxv7AeAmdE" + }, + { + "key": "a+6t4GzjsAsB6DGUyX9mJZnR/SzIUV25tgym9P2Ejqk=", + "nonce": "bRlNb4Vf5goV/I01yPFn/p9mda0q6jfD", + "msg": "ZTB4nraKce0/N2XtBmIIo2Ctcjv10qdstobBWPv99N+SwuXyyuc2owt1ULjcq1ahI5dVEPKdAQjZ/VyWTFIkNvrlQB6Dt8RfnRSccG2yvZQd+vSVhKsRnt4NYKaW6arpBtg90/V7rjAWxvXjlVDaLIEbuybikCNNnRdXi5Su5w==", + "box": "PPRQbrY0G/VR7n2QK6NWd2CliqCQ6/Mu6DhEDuiihLtGX1C4r8AQVoGgdnN3yNljqvFRN5xaxhcSh9D/jQMxpsSyLeXr7aUFSwJxRFYylp5LhtuQaKmP0GoOZ6vMnllYrmcNYvOC0WdBin3dm+NJirAPMpq1WtXgcFMw1QHBnzyarC6IClsr7azAkCQj6HI=" + }, + { + "key": "6ermqufVSmweMpY3U04LRtpOjn/Md6egCuODvgpNV6w=", + "nonce": "wj2BeZb/HWacpIlN4Vau88vB3gtbxJwM", + "msg": "bF20YlekRktjJcdc5N2t7CUEYCZ/61kjJZ5cwA8Z9RRdrTZv6xUfoZj4xUvZkpcgC5zAaZi6iw7ErbvEEF0ErdEiCjfZlR5NmJR83Sr9uW1HdkeI2x72hq8KxqdlonwUd19WF/n6uGythGk53Vnnt5U+hA6Ia65gxrLJ0EEr2og=", + "box": "dLPvsArftZdHY7NTXM2y6M8ZNR8p2kDcIpcRaKqCrxhm4OZMxt2El3e+760zJB4Jqn6TpAA2KR6sz2V3J/2eT8dWotiYV/xkMiM8fX7PU1rZ6q8RrgSjwlmFe86E5A3i8d2fWRlI4/sy2uED8Uuxdi+OhRJzP6XoL/yuHFv/srz2Kc9y5LL31YlXn5BVgmXJ" + }, + { + "key": "fKBRGsW6D7PpTk4YaxHvy8BU2Z2pgDBS0PapQcujHmo=", + "nonce": "6iM82RgDohv1323sPqre64XqsSlqxWCd", + "msg": "Ag9LV1+qnPlby2Cz4VnJk8pcgbu4I7B7euolFvoHinWQ/57ux4fk+nygnV0Up4hLLL9URpJjhilGzREG3kkftzxBvuYn26qpfSEOZv9Wnxw27j51ijDDzNpDVPibWrhaFcbPu6X4firWs7mvFMBFqFMqUyVLSq+8NNRusAadGfQ/", + "box": "krWDCp7CIUnM6j/5ONcwEjJNgsaK7y7QGAqTi526yoGTZkuvs2T6pRJ9tyXD+4y/UJ1oBUlGqdzxodMrQ/qIXLndvomvidUdoOLBGJHUpVno1l41g4hDsrHyYKF8QRFc1KVCKTrMcReK14ObR2OAqVcavCqhWQytJfW7MaYAmGXhrspX5UzvOfz1wtdDHzBQMQ==" + }, + { + "key": "2VyPRfaWZFpQhVGFkd0lpBFSswVOcaRKCe+14s9Xn3Y=", + "nonce": "SLv1mPcaWluewiWYSgUWvqWZLL8UJCye", + "msg": "6nQbIPn6RPgHSKqV7mwdHfEi/JUC3S3nbdxVx89r3hGjtJjyNljVs7q5mtXhPlC6T6S2HRGGumjjX+4wShwoRVBHHUu4Ck5LIhRHFdfv7WL8IXKTmTn5WqR/Flmqwii1sph2apjlU7IOdcEPqn6vEnodsgdXzfwYfJ8Pk4vzqK1T0cVEVkqC3U5QkwlSqcfs34Q+EWZOsqP7YqJRMfQnrd4mReGcf9qhoiL0UfmWI7/wQLGHRcoIfOfsRUo7Jva7QCIEDgUfD9w=", + "box": "zx03Wu3cx++kN53O+C57IT7XgFYxg8bIfSHHPgorr60u3CXCCM/AwULvPIoqrw7Ov5gRvRkmivKel2v/X7oVjd23Ygn8VEhz5MpNc7pUl+vaWKBVXCkd28T5VELXW1diFinZicgfLEOq8DLxxYpqNT9jDdCctnwkZT2Qk1BU5EHrVvaklro6etWrZJx9gD8wligcYm2R7B+iTK/Kt8XbXalUZgbbRfoNfVmYxCZpjgEsZx0lJrqL4NpqalrpTc5LKA5g/4JFdfiGMcs6l6KTdaN/jmD1t9vd" + }, + { + "key": "GTWr+lIzAOvKXcAfhCbwWlRbwlrm1pPeCzwU4BfnPIg=", + "nonce": "FCDNb8wvKNSdpfYlojKYHdbLTgvJp+W5", + "msg": "4PGF3o+SfxJNA1ZCF5MDaUmvxX42DgTybCXeRmbrGSwWRW6xi29WeWacrdMPa3o2mWiF+5iUK+mP3wirW9SeF4Tjj32jfPzJRtgwC1GC4gq05Jomjwwujq4bN+bKaUoVsUZksqwym5xZU+Z9nKJbZIuzB87QNd0yVtyXP3/rWnPT6T4mtoJxHvJMbDFJI3VlyXmRaPLk9C3xwmcNXwNF4DzCCr13AIceuRew1yDT3GVyYONpZ5OqeyE76QPmdtJQ1FUwdVL3KRiaS34JV77fS/IeLHMtYy8gcYdLCZTE84olk6g1sV18kvsG9yPuVoPM7/7V/pUe4ouaUvMFGez1o/mtc2Puas41eVKbhBb223XJASmHZLFln5cWRGvRKcGfBv+1VbGlk4rzFkt0v0JH+E8bg8g01Mvg9nsdqlnpre6ReWnSzIPJIJ5GgfQiO3lIg2MkrH+ry0HLhrATwEKmKYQC5vsseZYze3CNp7LcvbazyvjVK9jHcUXhicD8D3ea4E/o4/RCxnL+rfvDO2UlqxGlYUrEVkN06ZcUR3PWLqrbkq6YuK5xYdkcL7y+GQ93XV7FarqX7qI0BowXSI8rWoz+dh8UZrmNiysbtSJP0W7fzKEN5SVCR6Zi5U5QxutI8p3BWw18Q7cLWm9EsGiVdxZxeSLD0x0N4Hft57UStu/u6iNC2T7UAy/5U+/OYooy/J4PfvalfK2BOvkfkY8elnwEQ95SHSMklp9q3ZPWej9Hgr49GJ0A1yC4eVerckUTobVozi4iEiAtawVjTY1NPkOOjYwEWX9CfPFrYcWlW+24crUPfd09U/pgBc96or3MVkAHwkkqpJQIULkib6kLR9UP/FIviIsEABWM1aN644kFige9Jcw7BQn9fdLzHdkPlftIN2d1eGGYJJGSbzTjW7zQeZZWjcD3EOMOzVtAILbjCZfJxTLY/WITqXTY1g/GAlZROtqNb7gS8cQ7oDvIWTlu0NyYHMPx8jvewQCRrgVV/p1wKj+Jk/E81o3aVxYtcX5zLv5lPNnDDuyzEI7vDIRNzYjOqjU83/bYR3dC+t2Gv6OmgYdJuRUTJp268yZv2mKyTHKT3A+DHJWLFWaxE5BFXQxYgzInnpPvo2vpWT6Q6sVtwo5I90YC/D8FRYo0d0N8tywWk80SVa4YGgEanEN+FSPlPGJ1C4XoTl1lRPehHrVb9hf2jwsbrEmfQ5z20T9PfZhu4d7Ay8Ihg7nfyDgBR+NHgdBeYjgUxm8sJHnBIj9j1OdN6tgvYUCME+zqM5YTZOL3o6LmEku+DKPdUx5Mk4iu4qXpef3E8RvlwxuRoe0rM0X8xQ==", + "box": "rJAa8TamyAWmUFj5zIMMzvOa6KrBNf0//YOiygryt+3wncMaIvuXC2Otsla8yy1l5bxDGR6QJHqXQNs7mNw8XuJQcsl38BEa7sqZ8mUpCD0Bhogl4wf/IJjGujjKGTAQxxv6pzH0APcN6PV6q+URLsU5YM1/LVL+Ek/Ical5HGWvi/Vdy/HLe1IH8/a5xahdcpkCdL9E4zPPZ+2eACKzuz/tGgr06tOzcu/jgkj1Qv0Vpdjak1tMw6MF8/2aPSZSsZH0ow6aqJt0TOVNXVgTzOUvmydUNseY/psBG962GEMXjx4hfgrMtwsLuP0jfXPeiTOXg9fljJLfNtramWPxvpTdTVnrlw/qxahcvFY3pk+wNFeoQ5TW1BdxJmgy3dq+jQwGj9xSxBOb4C+WSEvqkC26bywjED961Wo7HEWgMGNpszgmZsG9NRCmHer9x3VtdK+KyV3cBQuGrD6MTI4Puk2jyDYxdWQwqARtFKVpEYjWcFQL3jF8BX1Zx2B8zZSHUpjXwNH9yo3i0IJvI4ctTe1T9Eht1LN/Tl0n7gsrQDZxg9CBnB/Aj/QFnxzCI3iXZSsea7BOaursQuSTU5Edn39SQbhWCOin/9SjOudY5j6oBnWg2EChzdExoz+xbvAzi6pA6a/jZk9xzPIR38zh9sR07PnGyjW6nLHAfqV20ZGTyiBEBuffv5yP6S8LfWyFjeNBVakr1UlzeRpQPYA124BTbQS+f/vO1aTxjMHEy+PxoK6G9I3yr8jlMSiSr0lc7eLnPNe0mgC3SxlMvGCKpxlFB0nymOVTS1R43EQcUDIRx6hZrH+l82gG10IzuglA2KkUdme07xJWjaCI/0He+TycwbWYzUL5Kv7tCEPkUt5M5/QdKTZyVh336f96tcj4g78meJq/M+vX7zrQExfYl2izspT49TPSc6D3yaDdcPuSwVy67eg0jecCSWsXCTgS2jUUe8gqGmjsNtZCbwJUQdO80t2kyNtRK3toKApHmtFjVdaclAxPWUV6IKowYJJ7wXIjQvz6Q5d2Y9giacMJqmS2fHT/ONueT6LQH65nJ7b4D++6LKX6MrE/QWe8gwZmaPhqU1VmEVukqMte/zAsSfW99LESjOkEds42RnarpQ/RAVC/ZBOnh5tYwPwMwD+zCAWNZDxRcvMNLsrcy/G2HrRX6M1syjNFAsKtCTCrUhd4YxHABEaVJBO4VtArFRBBFbrXaF7pKQbAaHT4SKTo4yXScegZbt+RqBiVgLfe9et1aM/W1IEAXSjPnBC4eX3cNsDvPqugwndjJ8j6l7VG6dRgUptfxy5fZ7kJqMnRR4iUdjZM2DB9Za4yRnfthwLh1IbvOB+hZQA=" + }, + { + "key": "C4+f/dQBZI6huZ0A3vu1XQk1xlePvW/RhZnb3Le8AaE=", + "nonce": "BW4EjrvXHt4pBfjzIv7F53CbIQvvLnox", + "msg": "GilTxhUKvMjSTfbFghBngyEaRbgAZFKZJVOCsgfsf9VNxdkAXJy3uCeGF3zIZ9OSLrvQ7MRcsuZhYkr24TZqK36QnURqQZGHaNfwW5v10tbpmmJcpo2cPQFgcbQrY6xvFWN91DN5RJnq9q4fpaurqG3b3bEUnBM9liZyFQVO8WmOzJ2I7Rp27dndUJ6HWVapvuhJqsLA1GOynfeCtiLBxBSBP1UXlikb0uI+A1o6WQn5zUfQzVmaZDNoE2zbJiFaLU2Jo+2TuUUN09Cf9AxPZ1EsuHeYFjojt8258Z0Pf8ohRFl/sXwa1uEXAigfqGNxjPn8BuUocVPQpHy13JeF3SCTw2UP10fv4dV1xMOnqQyT74aV8au+2e/q/BXmpedX9KwH766zROx9KtpBHxcgbjGP5FheoEjpz66cNNtTV0iYDMNk2+/iICVv3QJ5/cadlgeEc7S/bHRIhQCHB7R5wvgE1Q5HgWJGh7VMUBVQ66MqWXB1cpbZ40lyTPJYYsvfhsUux7Ju8LRGWB6dh8yb8Cy17e6TVr/PABYE8BnSMEJ3J70/F1LtFfoBwszaZMLU6xM1c0JIqAD6hCJ5dQtDScFvp+cQzjHHqmkESX4/HehQYPB3nQrGABHrrQVItVY0l9CJaN8SuFupyUCtz82j/wUaKkWK7Zhui7BwJdL0YCru4BG0HP93Skd/iKGnJxypJihdNZurmcoL8FKHO7J21L6IR+4MiGpy24i4WCze6Torj04Kp5V2RrQLhx0enaymu+lMnG5U+xpmKbxLPAYlleforDpqAG+p5DF2EBtqA3sgyoCDYJ7obC6MDOUv5FpE9DC2RtJsCNbp0OS2KQ1wZIqLZxy6nfZahJZ2u/KIJOPs22Juyn9WWH9gxdslW7InlHHf7r2g/8bXLJEQrhn7psXyplHVSQlhJEwaF317ywbHSYGGxBkvZKfjj8QEPqRDE97waFRMOH26lpKSdeCB7+FNU0LG2xcXZ+IgzFjOqXvu+ERKZHi/sQMdkHCHJqlWmJYyfzlq6OoBalxdhi61lnlyrHD9lg/SJgzx+C6f0jxob7NZcjP/MewiXG+Ke5ZOFTpGTyyUdrX1EnmcMyP263fFCBUrbIqkoj2HGvaeqRxxtn8i9aA4nfXFsQmUVFvLFFYVb/wY36XStWgyzU1igIu57rS2qo7oJSZ/hz1Hf01gQY5HFopaYzc0+wPjqCcvZ3WsclQMoG9buHOxFxWD8AviC2iUtxMGSHijsZPxmQdGyTRl9AfEsAUD+PuO6cD4mp2ImVrRKvMGi9t6YsZujEhA/nK5ykDYuDZ3/tvrVwAig3Uhz4A/tCSQVGYNPivcbmfVnZqA2cydfQmCeZ1UYeaawekU6K6skWAvXber/tACPWrHEzmnOfJ4k3jhVDyIi7E5isgWoyYyaXUuteSXOxDEIsEdKVRBHRIKMtqBkwcW/1Il+MH7P3JRn45O39oRMaeTVgTuYiPR3OdajJa8DoHeq3x45VrqNh8BhKB5sv4kgPefB5Pc0Gshgk7ZvUODqD+5bgaxbwWqfIhr964TqzuMZqYgGkbiGz3kqylWYMsLAs9gx0yCO49ZklY2f/KPipYy/3ejo9BuQuArJ7jMp8h+8felOJq+eB5XIylvpJ3w6gtAb6lfUC2wVHzDvNAWvrBK4gQG+7IxmV+HEQRTXD2UD9dW6CVfsIdWJIXSGL3Xd680U+IUMiiBZnMkF8LrcsHi/rLSMXTkVQmauTvV7nxkYDwRCK+CF2mCl2CMNQsebqcytEfFAxIlMB16NvWt0X0YeeaCLg7BgFbaqRmUBrf3/U2CEOLZ+dpZBeebi6VU0RwDBPoq19gpQJgAI2Rkji51G60fat0fiC+JVMbP0Jtj8OYMczeB0soyvMjAF+6HTBpRZZ9WsMuIqH4KOwa/933Niu5LtqWhJu1SMn5HEvKyOBrQSMadTNGGWSC/kevTgVEX7LcXtST9z0xRzq+pieokDsAAZNlvYhkGUcQ9NbJ2+LTp247dvRkqsK/g/f8FyxGFf+jgOTa9hHat6g0c853ZrLXc4tdachkewtAJVusYlGEO3sXjfnEQ8puZj9ozNTnaQopXmDUukfJO5oCycsvzMabWqzI8nXzqvXE3bRsA1I14Kc1qd5jjXmD2+UEgAUHqPcjU9ib+z/97wVygWSIhSdIyjkri/T/LFq060yRqfIWk5D3HnyCP0uDlphWXEMGZ4R6jXJUB69l53C5/XgIM/f2eQjZxF2MTs17/kNTEm8wWo32lie0buD6k70Dvi4Iixc/KhCDKc9gQ8nI/OgZMApTN+buL8/JR3On3whmVrCDh6uUsR4leoKnJnbs6ynqWMpj2BvKtk/N5r1ihruelaSyeu0CA31mGKcuGD23AikZpfmlitYPXG6i5rWSQVuVtDIPg0TrJzD4aXRtDOdZWBRpdyuiso0jyF6Ptkfl4Mz4ixkg093w+FCvAcxLHQidNV1RrQAxtYjBQh0twWvaTBcKaJazddbH9XxN+aSy2pdKUdAfyoaoFf6SuhWPObtJODAIuB+2g9Bna20hRcyOiKDp+sRz13KvzbMF8is7YiIHDgA5AqzkAlGkp0UVknb8k5tEX9C2PysGWzx/ZsCpdsGkRjc6dWgixmJ5xg5sLex2fQ7CFtBv5DqOwi3hYkBWt7tTYtkoB1qVAYiEMN6IeRF59yNYRcOvXtS+Af2JA/iUeufz8Ebmj8QtUQkXu9ECXciEELBhu9KmD3gGJgKm0KuFjrce1Sojr986BPONCEd1DcbINwuPIQtxJhspOJGuR7Xdtw+XriWRxSCQ9z4dqu2hAMRvyB+7K0aXFRt3qFTopGMkpWfwo8SGPVDrxGqGhuxxNUW9v+lOy26DOqe3SkVKIbCDsMSOgnDsEM/hAlIiwxWM5e87eHpn/wD//rAEc5L2sl+lsxz5v8XsajU4RTw6zdC+u4B1vvFASQKFlnKHxiImFTaraU9ahxC36tPVXdgBfvKD73SSQ/Z0eqyTCvEb73VkcPX4tgli/6mUTeaHV4shm+nsVt983OiWEoECAHbXSOsBYdnSzXJmWFI4Ftb3idGjZXUXSvVYTQ81w5yNXgQXTqry1xHGEbqAbFy7x8pG9RynHIoZg/KHMP8g6mPV0GJdwd6bbrIfvKQPfvm/H9xmXuI5NyqwrjxinB1eH8+/ZyI+9fWptNecPDQTyRE6q+jg1rY2u62R5eLZJ1MAsoqhMXBBkK1dysiPRcHOL0OAzSyxF2Bk26tglw/Hsd7Z6pjJzeYoLkpLZbD2n/x0vEJCgqYwlFMCvodCDwz2UbVpavnCG2mnYrKsbAnk2cDKyWd6oz7X4noPpRgB2x1tdkHKaVHkKO/LTmvsd258ehgD5bSW0W7bwRTgTPaiEKgzOMk2UnJhE0vKJyCY+nWIdpoI5GBcD0+do92izR96fpITy0SWxlOs78pGs/AwjdgPq+KMOCynvrrrSrthIHA8rIMcmFykB3v7oei1Uzt5DwxK7tJtT/SH9U9/Bza8VnXWnIVgn1BcAAhCpe46v/thN4A3r91n6+NFle4Q2jspTz8yKqHnJkLa2ppVOF9xtz/8ANWE9436UMeucwrIC7to9frqn02tE++tqEhx4ETWWnyLUyLVz+zWQ1mOt5pSFxAPRwOhOA6U1h+A8U1z0EtfG8d3fGSm/JlIuLTuLx7ZFrs6jZko9zLQ7wirjH0muit0n9uZD+Y37tT0TjxJ/iBAA4J9WpQPEdasSKx8b+ALDeILrKdjM09znnOazpJa6sa8CBaY/AA4bQM0o3YV9F0jdlQGP+T4dyljwlB4TiD1+udznDWTE+SU2MLozaEsLg+sY4TW4HOmsDUqfkhl6Qc1o7cIWEWJ3fuNyITDoqfzLMS2IDWgqSRDPmxKkUqW0oTV+wLw92JSmxz4OLdD4yKATMgLf17426YFxzW5J3+YZGd0aw23dvk+0ktsIv9iXigg8gLRhIjbgQV1jQthb2JAhr7gJcsV5TTFJ0eMURUozh4x9qQoTGKjz8cN30bE759hH0+HHGmGfekyvnpjvOcuQ34Br9ocrLUfKh/+pP15X5aGSnL1j2lbFMdtsos3jr+RAR/R7KcP6s6APnxFxr6vSHBSzqoLvVZYFZ8IT/2U+6csRTdv1sz9KAiHu4vdvAgXDIGlHau2cplcpglVdYFLw1fecfl7wPu03ZUXBZzZnn1wWDxoRLm2cqlsH8aRIip+8o7mlt4T5ikhTmGsRgHlGwLBqsEBR+qCwDwGnX8q8bMxpKBcmpb/ZhnTk3Qelmsm2RbVbfpe4GsgTgJMQnBtYSWBBVUO7PMm+BPsRiDM4D/cqFtvl9+m0lY3OeclNdUcO1XDTbiiJq9Z1WrkEUFHI8yyVvwF0drj8uI6ceIvzUyK19QmVC6pFBXPfZXKVr2h9O2wTzvwCSghACbaRfKv3QhaBg6bfHZ2/fCuUMwpGzwTZuHD7kH31951+XueXyYdjoZrsZUGfznSo2oSS9iSZJPo4qGrvQjmACNr144DoTitNiF4lU/yUYC+hl48BiEFlJpGkRHcM6xEpVpjjwHE63Aw6WM7r9eFwXHgRruPtS6SmL/sVrNEo8JTK1G5IcLSTJpzWJWa9ZeNLhILsVpvbOJaSFooRU+JWUPeq7xXIs0YKwIyD7IStuYTt+hUA81KVfG2MiwnnBgR37wSImpI05IcmfythYbCdurinbCJt61dgXVcCZ7lWxIDl4l6Y2AiMbOiVsTHGJQ5WFbmMfpzA+r0VWz4lijLhz3LHRzrCiSHQoPXkGiBhmT71HIE+l92QGVfKmxkuwzqeR66qSDzaNa0u8+HHsn4lP9cXc1umBi9yxAoLEyY9iCJ0DcB79swfauudHSRFCI42ShvFkGQ6l67M8HAmMAvSNNreZDF8GCrm6azRpJ2Hs9PMFZ3Yv2uTtnyfMs9VrhsPHWbxjyrxY8dABvMzzPLY3re3Ft/FEkZLNlR1S+2bJKT3kdQoFWYKILV8CBl4nV0WHsFtPE+uWdu/d6qTY66IRdzhCEwaqMdXcgjB06E4PvcXIpkTO88340xYYgavWOtcMycMISFfDsN3cFR7FaJ1bJH0CQ4fyLmBRp4XUJsdNBJpXlmd1pyuRZMFSt++gaEwM2jQlVy8eWzi9AQ9TiE9tPjs4CHokbhf/lD0/UCygJ89krRTvc16jeKJMFwyb99ZqeJPuwfDzSZzGBR988ZbqvdXWAfVwjBF9eUvqWK49KRKRe66sY8ywgn/JQ3KAmi6oVx6r+1mncFtzAiKWE4HdroV3JDfRDnrdrOWE6kntALzVG6e7KH2t71jo9CrXLJ1heOhzDZeLdf7RstPEq+vnPrtwYDmLGEgSaxJjD46anmqWNOIF1HfiGbcj1PFCO2HI2hFXuowZixnyZhCwRtrUbm3fq8nf8kBp8BaWwJjudYX6QTf4E8D4+pbCEv9rycKtvRFlw==", + "box": "WMvhao0hMyzkYfVfUkMXq253TVtxlPi72jlPSuVdGQJ5eRQdNhbSTWRyAH5JbkvqBpYC6vweZA7yZ1tA6YK8kLqgZ9Qa24+jTpfAoZrMml+HBe2LhsF+nEzoneE8u/lZzfFDcT8LlkFgn0SqYSD5ZwAcrKjbWT+sFwjHyxyWWqH6fgaSyRwKHAniIsyRIi9o4VpWeKb9DYpWYLQ12kl5vAEy18pfHhU5oPyo95puimxth6tYZGnXENsRkFSfIjSPsLwvyIvTI8/z8MeDiqIu2Lp6oCPSzXp7erDa91hW1/dNLUloWzv682hvw721J2zWr7Z+QBu5RE5A4ghYzyW8g9zeoobAzudhiVfkFNz9g7IB8muRq3bgAfgKtHIX+qDPesCfLCEbEEtHanUPkvg9i/XQH7r+qF30heotJ8EnssqA4gwJOo9x5IBVozLjT1Qj99ejN8UfghwsqV14ItNp8BezKaMt3v0uLbkZ4Zerf/6SPF8LQkEcQyA7JEQPsjJoxIvfIRBw9Nuee6PnXn0Z9VSW7TWGQeeJjrF8vQTsVKWm6qp0d0ZrSUVMkuRtF0BbHXSIQPtAshlMIqXt+ewF9wlDPVpDf6gOzuXViNbAJWQHkMGSyeKYCFlwgf13u7XFy8R9pFT9pciACtwu9YKoQyfu3fX+aj/pceaH/lo0POIxWdzx3wjoiL/OCZkJ81FxYIgNlx3WiV7MVF2sT27sgHju7fMo8fpPBb+Pl4DY6FAenPWSEZMMlLfuBXfTFyZ8TW0cvrxJtzde5EjqiIYD3ziRwBLUThjhT3TTNWNhIvBwPBqUc9nflp2H0BgUE9M28b9W+JEApUTgGlcL2UBiePJJB321v3N5EushukCoiv0/LMTWLjeNm0eZdxVWBsJRJP1ETcyMY7PuX9eordlQZPES7xOHplxn0/TrAKyJkvPfjYFmY3J6yBn1a0ssbfHTkgTvnKhYgRo7vxlhQN/RBKVIdmMrj+uY9tSEz+tk/ttFbcrXslXQaJ0LJ662Q9TGvkmboACy764qxYx2e8v5rnK8Emb3tZzmu+NoPv7y3vYSzJc9MU9b1BOR7XBHwIBIW64e3IvLw96BR6ryKEM+T1pRcOW0vvuRqPpd687TWPpY7WsfizRA7tjRBCQViNhoB0dK8rjDEWkZ5yCTaYqGmbSMSRPkQyXAmVhjwdzVm87nQOXFTRMIoKZU6Cira5g7Uq/EkDFoM69qdk4X5Eqp+g37aGhG9C3XmDhkcWE+kG9Htlx+lEYGVVnFJRPwVrqzZArxTAS5k5FNZB2CQuYqAnZMexOxnvYAIfhabFiJzvDCUdR+ewnfo2wRaKYgG+l4G9gXQk9b4tdDaehPjWCfYqRIW+IFm9Lq6BxVyAaxy1iXWEXrdzp0s8paiMZ/i3u3JOLTH9IrodNOwvC1ik2lNZVUP4Bemx8FHDgvxLo8oyl4/jRC8t+2lNWHXBlgVLsIAO1DlXRvbkZ7mwYJOD1cJPV4TObme3ECbCQJBlwYwt3ljG3bqEj74SWnmXTe6+XZ/H4N6GRycZvMCutqfwhKFC+3oCzxM4s32sFPIPVmdRJSmY9hdUOKziNuc3CtVKPC6JNzOXcGMFcVGXV17AwfZmWrYT1H3g+LgX8goxy569EEpBcwWUWkKnOkVDxl2n8VPx1ODmcZv6Ta+Txyj//3sThhQEnsHOzci9KO+CJzHny37a8oLut0AYUreQor4O91y+MHz9dm1eWkqa4JtpV03lX+o2xOMx6sZlA1Pxq15zgTaxOXVr22bEDpblnw5ECqFDh/vkha+zAQne5utnxs97Lu0O/PP8DHR6051O7ajmejVVzRFcpyce4kHcJ2rtNpFwRVRfpJsvgyeil2UjfAR3wpcXbpouIrin0TUQDKb6jsnVL9+illP62u0iWdenXMpBkA4KQET6nEWLoBQeJRS7m097cUzLtGS5wrOY4PgyG1f00UJhne1jsatLvJKotmNDlhRYMKs5m1Q193Cm6itvcCtHprRE0tyHV/NmSX1EO0i8ZhX2xnjy2PkwUQVBcOWUIgswEqSLwRldr4PYwXnLRkR0S+MF+Kk2SHmqGAGfZJKU60Pp7Y1wPImpJYJLusft5Erj9GzbOu8WHL8iC4WOtJP6CHSS/2lGQAv2xCPDzOc6I7uOHDKwf2c+LQ3F9G/ftqPNKe33Fz+o79bsslSCgk+CttkiuMaoApyD9R4/i0fQiexmqHV3J0uaA3ygec0vTZtM/qah9fn9tufGZ5QfrBo7GlHZWb1D1w5CAJh4JAXocFAHyEWmClwDucMkPSOP226DvtqW2Sjh8C2uouTN3kZlY0FZyLvOWInPBM98pt6ho2MuYgYd8/CD5pbdfDEE/XVxsMoFFz/ZvFiVA3MMv5zUEsYR6mqod9w3QPGEfmlRxrgU3O3Ua1SKkRxvuZ8l5lWlwpu216VilF4ue8QLinmkFr2ctabI5ef1S7tk1TIPiHipkIRG1MKp4wiSAU/W3CwZrc3YxVcS6wKD17SO3PSvquXPPlCTkz7EVJsZ7zCRUvsSgzk6I9jzcX53i4YPTmkQKdmN7VFnlTD/4t/R5h457z/diYZ04kFp3Vdg+luS7qL2Oyw7fmE/o8KUsizRV7YoEfj6xPh18RJ2Y5uXKFGPeln1X+LIoZ4T+1DBSnVfVgZZ4CEAsVqwW45BGLRFFlWXydun8R/TNkn6xi0PtFk6t645hfDOtHNW1mU7W1uGvz9k3w/jqF7pn0hUt+Fm21eOeIWV8eIxiB5XavA4TnoZz/82E1iXN+InYuLWHU8E/fRSi1u1eBzZDgb8XsjnEWgEyJjeJQw2YzISLmHoeAID75ZQl+i+DJAi4l7C3TJXoVI2LnOhdosefn8K9FFpdM5faSsGA3NMPxyhCOOjGGxNi5OPI/lTRakkkGkgMjgbaUN9/SbswZsTrwrNFAt8aGwtNb75onrPddHL6t6Xtq/Zjgj1xxqU1K7bLJSEN83FeRWEdJlbF/5IQ5urRZX06BtXzmECyXvceL5wtxADdbGemjUyjW2WEocajR8l8u4NayhKGaROVybll1zpLOVdcuUzjf91u7JVGiWesU955c+wJbCtSjT8e38g4AUWw+wTSHtPBIP7cWeaZm57t1KjoXtIAP+3nLmAjnAYwkbov3AV393QKudFiSvvSG5iopsOr1z1wHVNaV5o29jNUTa8jo/yjcJURypQ7J9gWRwWgsg0jAgbdVm5GCP09UHHKmQtXrnvozxihEB9/HiAMYe6QPY4CD2u52mWTlzrc3gtGQzShWzaSCfQDmHoX0DSlRqjoiNd4C9nEumKd/Bna6oxSF7RkciscCk3R0lfCaXjLKxJXjxjpaVdTpfIVcBOfIBo8oS+mPRxhnhlF2idju/eY61rzu5yDxwc2A5rObYOwVRc4Dw+n3laqlW0Va2QxlZHqwqJDi0VPdFKJq+aZypWA7CZoIj7PRTTx08IFBRChVntytr0BQIU/2GirdBaWPyi2nXyJzlQFvu6kHvCD7rwsL6TpSxZKWVLjxnF7Y5MwZmhkCttW8ZKJsDBttW+AFIcO3aVBO7Ax0IA4QQEmL/2wUkmvWLUoBLwi0a2DL47NyGjVxRuxC6kX9x/U8asymF7bTpgvyuOugo6hMT5wMdohmYArLXQHuE5TrnzLs9A6gkg3Y/TuwzP4ycueOB2KbZUotSZjvDI2fsCt+a+WSiTQLtTG7a+ULF3GsJRIu00w6Sl9cYq7PKi71YSVRvp1sgZR8CCkE4DFwpzYt0RJ2oXYiYVdjFeflUGBZnbsjXl/EE28BpWgQNuWpDs0sOS5APgfY1wu0PnJR0fKmRAr+WCPTAcIpoyiWF5tVaoNBfEGEK9A7Pq7e2Oh5IhiYK0iQXGfCRjcYFrVGeGW08kq2xngWmFqunYAd9FdSfHZh2sPyauLNgmOW2DGSsZ/YTJvYSmupmWVjy+yICDJvpby1N5JqgMs8gXP90iewtcBdjsRYZRljNGq3cyDjZZlK6KjVB9WL7lOR7gX5jWb4H1MPeFYPyI3uXm/lHGBlReIjAMYpsSgIRBd3NS6qbgAW9azx/Mt6ID7RSJnnfpGlekTbk7jbm6yU69rDl+B9854nyrxi+AayfOf8yM/EvyUrZ357h2B7rLITiWoesb8rSnrppGK0i/if3MF+v/736/VkVfnk5Y0QhMKGiDsWH7fj03h/ijUfulWnM2MThRRip+OEd1NYgHesXHtZCdpmKCZDCfSEjKt6SKhO/DXhgvSFxvOOTF+WMgfhdNARJvYLT9OR+LL2wvwO7jsUhpEW2Row6I4foHT2rEIiyeOwJgYm2XUk+cUOf6Z102qj1Cr/bOx5lvgduUUqTlAUHdQ8ZT8wem7Zvfoyxfl1j+7FNbZh7pNfSoklyuQvjfZhDMwBI91hcE1unNybUnsG5DNDT2fe4SEKO4UAj3gXUAAUc4MvFeN8aqLptKv2fSMNUSSYJDQlXdjvPN62YnPiDmfkRiJwNcYcPCEhTGNoMD71Mz0TSolrLCybOv5U+rHURnFkQCL2BzVv6OoOKVGcv6braX8gDU3jx01XXahocuTlkKlHukZRLh3UDLhsr1wACKt9ZVlcZ84mBSq8i974RtPxaI2/DN0Ixpj2fbtBKf+b4r/6eds3u3Et6i7H0Rz0AJV7qub/ozjT3k6M8SPo1f07JG7i+W8rwzZenIMLTWYgqOal2DVOfGnRjLV2pke5ulOjLw+jD364ZNsOyTVmvxtbX6VFenPfqJ4Mz6Gjy5BrHjEN9odaezXldxWLfSVmxWWhZWloiJk+SbPiSjHjMXZ2JaoHU+TpbQikT3mW4h4+Cjj4A+lVZ7H7lWuLdLJKqJj25lj56WLdY1WK2T1QnCmDUA2iX3xMMqzi/eiIUfYMY088W/CLu9mOwLvrdYaUI1EZByKkGIQYS1c4MuvbFEROo48nNXKpYX7jmAVekjXU2sjSQRWjTxSFtIGtoeA56IA+lQBaos//lnkhnrisQtk+RrN4u4j0tjK7KdE5/PKqehb3832JiSSjw04PxTTFxzAaouhe2lh2qNloLOLyK/lkbPbFCNVyij+WWeXgbQ+BKea28tAPacP8b8gdyyyKGOQ3TOZuuiBRLLkAKNQEDjQGK9d9nH1/kkfpENCOTfzhlnXWphrnty7jrSmydZjt4UyD5WweyKGrrN9OtBuuCOoxRKAdcg4xmhbC8hLaRdf4O+x61yvc+sM+IXQplvwmzq6JOKGTj8KI5PyIDhDwWJkNLcP7bTOoBEY773loWOBYmJlMArCGz9zG017i1iesp8ZqkNny1lWoP0NjvtoF4jvjT3W5oVYW2BxLn1p8Un1yEbYljj8ZOi1c0Ta4y7WUQLH5rJ2bX/W82uaGfC6y8xPVWaAhVEb9YNRh1E3GPqn6tB98/628tulTk6ppMGgX/mgM7AyQeGU5Ll34WerjNlopxurN16vUS4miU/oytuKdGfA0VT8whKM=" + } + ], + "hkdf": [ + { + "base": "6qSRIC9cXjjs9wjHKGA/+ZNxs/n+LlIcNHi974iMcQQ=", + "hostNonce": "LnlCAblfeRX4ekGWMdRi3A==", + "clientNonce": "TbL0CesVZRpu9MZXjtVI5Q==", + "sessionKey": "cbbBEZEwmIMezBw8/Tz/jZTFa6OecaSytMjLuuqsl2Y=" + }, + { + "base": "WITxl+9rsRTHU8MTCBFWTn8r23t2LjOY6Q2NpVVExtA=", + "hostNonce": "wTJNVngovHfTbRVgtrVpVA==", + "clientNonce": "DEAMiy5hNcTDog0gLFewpQ==", + "sessionKey": "ooAGaBScUO44Wz+qvM221EwGaufEzkodg0lucYdDkqw=" + }, + { + "base": "zIWLTEP5GBvucp7D5OpZfgJgdvG6KQkz0WvJ3v65m2o=", + "hostNonce": "oxG9zoym0ZQbTQGAAc2Lmw==", + "clientNonce": "K0CRBb/x0LP4qSz+5Ye5ew==", + "sessionKey": "U44SJCbGrTKH/Rj+PGW1QVivtvI+IjyS+NHXusWNn/Y=" + } + ], + "hostIds": [ + { + "publicKey": "nIYiolgjxDE2y0JyVXQG4HRLZu7f1lpOn0C1FGK2Sm0=", + "hostId": "FjJaGRioHGpL4nGksm-DsN" + }, + { + "publicKey": "A23MIH+JtHGuVHQY7S5E37eCeEVvd2jTrSU4ftZuWVg=", + "hostId": "bow5GgZud2f7i2Sd6pjQz7" + }, + { + "publicKey": "bd7yi6jKZlIIoCsoEd8Sgo1g4intSDdYdEKWOdYdXQY=", + "hostId": "iRIKuJCNZz4WgmkYR0Yiwh" + }, + { + "publicKey": "VuXyx99vMR22rHdUW8PFLypPYxVWC243fQcWFH9xBHc=", + "hostId": "UxYswzvmG7-gkYStmudJIO" + }, + { + "publicKey": "2PR8TwWw3xk2sRu2Mxp0AeF/pSyxJ995BJjI4Fb4fUY=", + "hostId": "LlMo1vMjn5bUvXtQO2taDd" + } + ] +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000000..4c13679278 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,22 @@ +// nodeterm for Android. Two builds: +// - `protocol/` — the pure-Kotlin wire layer (JVM; testable without the Android SDK), included here; +// - `app/` — the Android app (Compose UI) on top of it. +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "nodeterm-android" +includeBuild("protocol") +include(":app") diff --git a/android/tools/check-beta-version.py b/android/tools/check-beta-version.py new file mode 100644 index 0000000000..d202f8caf1 --- /dev/null +++ b/android/tools/check-beta-version.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Validate selected CI beta overrides with the local packager's version rules.""" + +import os +from pathlib import Path +import runpy +import sys + + +def main(): + code = os.environ.get("NODETERM_ANDROID_VERSION_CODE", "") + name = os.environ.get("NODETERM_ANDROID_VERSION_NAME", "") + if not code and not name: + return 0 # Ordinary unsigned builds keep Gradle's default version. + policy = runpy.run_path(str(Path(__file__).with_name("package-beta.py"))) + try: + number = int(code) + if str(number) != code: + raise ValueError("Version code must be a positive decimal integer.") + policy["validate_version"](number, name) + except (ValueError, policy["PackagingError"]) as error: + print(f"beta-version: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/android/tools/check-r8-output.sh b/android/tools/check-r8-output.sh new file mode 100755 index 0000000000..f735fa1484 --- /dev/null +++ b/android/tools/check-r8-output.sh @@ -0,0 +1,84 @@ +#!/bin/sh +# Checks the release build's R8 output against the runtime keeps in app/proguard-rules.pro (audit A37). +# CI runs it after `./gradlew :app:assembleRelease`, from android/: +# +# tools/check-r8-output.sh [app/build/outputs/mapping/release] +# +# R8 finishing proves only that it accepted the rules. A keep whose class was renamed or moved matches +# nothing and R8 does not say so, so this checks the keeps MATCHED what they exist for: +# seeds.txt every class and member a keep rule pinned, under its original name; +# mapping.txt "original -> output:" for every class that survived. +# It checks only the targets named below (bridge methods are read from the source, so a new one is +# covered). A NEW class or member reached by name needs its keep in proguard-rules.pro, which nothing +# in CI asks for (except for a WorkManager worker: R8RulesTest does), and a line here so CI checks +# that the keep matched. +# It does not prove that the reflection-loaded code works on a device; nothing here runs the APK. +set -eu + +dir=${1:-app/build/outputs/mapping/release} +seeds=$dir/seeds.txt +mapping=$dir/mapping.txt +bridge_src=app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt +bridge=dev.nodeterm.android.ui.TerminalController\$Bridge + +for f in "$seeds" "$mapping" "$bridge_src"; do + if [ ! -f "$f" ]; then + echo "check-r8-output: $f is missing (R8 did not run, AGP moved its outputs, or the bridge moved)." >&2 + ls -la "$dir" >&2 || true + exit 1 + fi +done + +fail=0 +ok() { echo "ok $1"; } +bad() { echo "FAIL $1" >&2; fail=1; } +# An extended-regex literal for a class or member name ('.' and '$' are the only specials they hold). +lit() { printf '%s' "$1" | sed 's/[.$]/\\&/g'; } + +# 1. Every @JavascriptInterface method of the bridge is a seed (kept, under its own name). The names +# come from the source, so a new bridge method is checked without editing this script. +methods=$(grep -A1 '@JavascriptInterface' "$bridge_src" | grep -oE 'fun [A-Za-z0-9_]+' | sed 's/^fun //') +[ -n "$methods" ] || bad "no @JavascriptInterface methods found in $bridge_src" +for m in $methods; do + if grep -Eq "^$(lit "$bridge"): .*[ .]$m\(" "$seeds"; then ok "bridge method $m kept"; else bad "bridge method $m is not a seed"; fi +done + +# 2. The workers keep their names (WorkManager stored them) and their (Context, WorkerParameters) +# constructors: the background check, and the answer sent from a notification (audit A25). +worker=dev.nodeterm.android.notify.InboxWorker +action_worker=dev.nodeterm.android.notify.InboxActionWorker +for w in "$worker" "$action_worker"; do + if grep -Eq "^$(lit "$w"): .*\(android\.content\.Context, ?androidx\.work\.WorkerParameters\)" "$seeds"; then + ok "$w constructor kept" + else + bad "$w (Context, WorkerParameters) constructor is not a seed" + fi +done + +# 3. Classes loaded by name keep their names: the workers, BouncyCastle's provider, one of the algorithm +# tables it loads by string concatenation, the X25519 SPI sshj's curve25519 key exchange asks it +# for, and an exception whose name user-facing error text can fall back to. A class R8 did not +# rename may be absent from mapping.txt; a pinned one is then still a line of its own in seeds.txt. +for c in \ + "$worker" \ + "$action_worker" \ + org.bouncycastle.jce.provider.BouncyCastleProvider \ + 'org.bouncycastle.jcajce.provider.symmetric.AES$Mappings' \ + 'org.bouncycastle.jcajce.provider.asymmetric.edec.KeyAgreementSpi$X25519' \ + dev.nodeterm.protocol.host.HostException +do + l=$(lit "$c") + if grep -Eq "^$l -> " "$mapping"; then + if grep -Eq "^$l -> $l:\$" "$mapping"; then ok "$c keeps its name"; else bad "$c was renamed: $(grep -E "^$l -> " "$mapping")"; fi + elif grep -Eq "^$l\$" "$seeds"; then + ok "$c keeps its name (a seed; not in mapping.txt)" + else + bad "$c is neither in mapping.txt nor a seed: removed" + fi +done + +if [ "$fail" -ne 0 ]; then + echo "check-r8-output: a keep rule in app/proguard-rules.pro did not match (see FAIL lines)." >&2 + exit 1 +fi +echo "check-r8-output: all runtime keeps matched." diff --git a/android/tools/package-beta.py b/android/tools/package-beta.py new file mode 100644 index 0000000000..a66324cccf --- /dev/null +++ b/android/tools/package-beta.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Sign and verify an unsigned AGP release APK; never build an APK or create a release key. + +Passwords are supplied only through local files understood by keytool/apksigner. Successful output +contains exactly an APK, its checksum, and beta-metadata.json. Nothing is uploaded or installed. +""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import zipfile + + +PACKAGE_NAME = "dev.nodeterm.android" +MIN_SDK = 26 +TARGET_SDK = 35 +ANDROID_DIR = Path(__file__).resolve().parents[1] + + +class PackagingError(Exception): + pass + + +def sha256(path): + with path.open("rb") as source: + digest = hashlib.file_digest(source, "sha256") + return digest.hexdigest() + + +def run_tool(argv, action, cwd=None): + try: + result = subprocess.run(argv, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120) + except (OSError, subprocess.TimeoutExpired) as error: + raise PackagingError(f"{action} could not run.") from error + if result.returncode: + # Signing tools can include credential-related details in diagnostics. Keep their captured + # output off the log; no password value ever appears in this script's argv or messages. + raise PackagingError(f"{action} failed.") + return result.stdout + + +def signer_pin(value): + normalized = value.replace(":", "").lower() + if not re.fullmatch(r"[0-9a-f]{64}", normalized): + raise PackagingError("Expected signer must be a SHA-256 certificate fingerprint.") + return normalized + + +def validate_version(code, name): + if not 1 <= code <= 2_100_000_000: + raise PackagingError("Version code must be between 1 and 2100000000.") + if len(name) > 100 or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]*", name): + raise PackagingError("Version name must identify a beta, for example 0.1.0-beta.1.") + + +def regular_file(path, description): + if not path.is_file(): + raise PackagingError(f"{description} is not a file.") + + +def password_file(path, description): + regular_file(path, description) + data = path.read_bytes() + password = data.rstrip(b"\r\n") + if not password or b"\n" in password or b"\r" in password or b"\0" in password: + raise PackagingError(f"{description} must contain one nonempty password line.") + + +def build_tools(explicit): + if explicit: + root = explicit.resolve() + else: + sdk = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") + if not sdk: + raise PackagingError("Set ANDROID_HOME or pass --build-tools-dir.") + candidates = [path for path in (Path(sdk) / "build-tools").glob("*") + if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", path.name)] + if not candidates: + raise PackagingError("No stable Android build-tools installation was found.") + root = max(candidates, key=lambda path: tuple(map(int, path.name.split(".")))) + suffix = ".exe" if os.name == "nt" else "" + tools = {name: root / (name + (".bat" if name == "apksigner" and os.name == "nt" else suffix)) + for name in ("aapt", "zipalign", "apksigner")} + for tool in tools.values(): + regular_file(tool, "Android build tool") + return tools + + +def apk_metadata(apk, aapt): + output = run_tool([str(aapt), "dump", "badging", str(apk)], "APK manifest inspection").decode("utf-8") + package = re.search(r"^package: name='([^']+)' versionCode='([0-9]+)' versionName='([^']*)'", output, re.M) + minimum = re.search(r"^sdkVersion:'([0-9]+)'$", output, re.M) + target = re.search(r"^targetSdkVersion:'([0-9]+)'$", output, re.M) + if not package or not minimum or not target: + raise PackagingError("APK manifest metadata is incomplete.") + return {"packageName": package[1], "versionCode": int(package[2]), "versionName": package[3], + "minSdk": int(minimum[1]), "targetSdk": int(target[1]), + "debuggable": "application-debuggable" in output.splitlines()} + + +def require_metadata(actual, args): + expected = {"packageName": PACKAGE_NAME, "versionCode": args.version_code, + "versionName": args.version_name, "minSdk": MIN_SDK, "targetSdk": TARGET_SDK, + "debuggable": False} + if actual != expected: + mismatches = ", ".join(key for key in expected if actual.get(key) != expected[key]) + raise PackagingError(f"APK does not match the beta configuration: {mismatches}.") + + +def certificate(keystore, alias, password_path, keytool): + return run_tool([keytool, "-exportcert", "-keystore", str(keystore), "-alias", alias, + "-storepass:file", str(password_path)], "Signing certificate inspection") + + +def parse_verified_signer(output): + failure = "APK must have one verified signer and an APK Signature Scheme v2 signature." + lines = output.splitlines() + counts = [line for line in lines if line.startswith("Number of signers")] + v2 = [line for line in lines if line.startswith("Verified using v2 scheme")] + if (counts and counts != ["Number of signers: 1"]) or v2 != ["Verified using v2 scheme (APK Signature Scheme v2): true"]: + raise PackagingError(failure) + certificates = [line for line in lines if "Signer" in line and "certificate SHA-256" in line + and not line.startswith("Source Stamp Signer")] + signers, ranges = set(), [] + for line in certificates: + indexed = re.fullmatch(r"Signer #1 certificate SHA-256 digest: ([0-9a-fA-F]{64})", line) + if indexed: + if len(certificates) != 1: + raise PackagingError(failure) + signers.add(indexed[1].lower()) + continue + # Build-tools 37 labels a single signer by its highest verified scheme. Its verbose + # count is mandatory here; numbered/multiple, unknown or repeated labels remain refused. + scheme = re.fullmatch(r"(?:V2|V3\.0) Signer: certificate SHA-256 digest: ([0-9a-fA-F]{64})", line) + if scheme: + if counts != ["Number of signers: 1"] or len(certificates) != 1: + raise PackagingError(failure) + signers.add(scheme[1].lower()) + continue + # ApkSignerTool prints SDK ranges for v3.1 instead of numbered signer labels. Preserve + # the single-certificate policy across ranges, rather than counting each range as a key. + ranged = re.fullmatch(r"(?:Signer |V3\.[01] Signer: )\(minSdkVersion=([1-9][0-9]{0,9})(?: \(dev release=true\))?, " + r"maxSdkVersion=([1-9][0-9]{0,9})\) certificate SHA-256 digest: ([0-9a-fA-F]{64})", line) + if not ranged or (line.startswith("V3.") and counts != ["Number of signers: 1"]): + raise PackagingError(failure) + minimum, maximum = int(ranged[1]), int(ranged[2]) + if not minimum <= maximum <= 2_147_483_647: + raise PackagingError(failure) + ranges.append((minimum, maximum)) + signers.add(ranged[3].lower()) + ranges.sort() + if len(signers) != 1 or any(previous[1] >= current[0] for previous, current in zip(ranges, ranges[1:])): + raise PackagingError(failure) + return next(iter(signers)) + + +def verified_signer(apk, apksigner): + output = run_tool([str(apksigner), "verify", "--verbose", "--print-certs", str(apk)], + "APK signature verification").decode("utf-8") + return parse_verified_signer(output) + + +def require_unsigned(apk, archive, apksigner): + # A failed verify can also mean an invalid existing signature. Refuse its structural markers + # too rather than silently replacing it with a trusted beta signature. + signed = any(re.fullmatch(r"META-INF/[^/]+\.(SF|RSA|DSA|EC)", name, re.I) for name in archive.namelist()) + with apk.open("rb") as apk_bytes: + apk_bytes.seek(max(0, archive.start_dir - 16)) + signed = signed or apk_bytes.read(16) == b"APK Sig Block 42" + if not signed: + verification = subprocess.run([str(apksigner), "verify", str(apk)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120) + signed = verification.returncode == 0 + if signed: + raise PackagingError("Input APK is already signed; use AGP's unsigned release APK.") + + +def verify_build_inputs(args): + regular_file(args.build_inputs, "Build inputs metadata") + try: + recorded_bytes = args.build_inputs.read_bytes() + recorded = json.loads(recorded_bytes.decode("utf-8")) + except (ValueError, UnicodeError) as error: + raise PackagingError("Build inputs metadata must be a JSON object.") from error + expected = {"schemaVersion": 1, "signed": False, "sourceRevision": args.source_revision.lower(), + "versionCode": args.version_code, "versionName": args.version_name, + "unsignedApkSha256": sha256(args.apk), "r8MappingSha256": sha256(args.r8_dir / "mapping.txt"), + "r8SeedsSha256": sha256(args.r8_dir / "seeds.txt")} + if (not isinstance(recorded, dict) or recorded.get("signed") is not False + or type(recorded.get("schemaVersion")) is not int or type(recorded.get("versionCode")) is not int + or any(recorded.get(key) != value for key, value in expected.items())): + raise PackagingError("Build inputs metadata does not match the unsigned APK, R8 reports, revision and version.") + return {**expected, "buildInputsSha256": hashlib.sha256(recorded_bytes).hexdigest()} + + +def package_beta(args): + if sys.version_info < (3, 11): + raise PackagingError("Python 3.11 or later is required.") + if os.name != "posix" or not shutil.which("sh"): + raise PackagingError("Beta packaging requires POSIX Android SDK tools and sh; use Linux, macOS or WSL.") + validate_version(args.version_code, args.version_name) + expected_signer = signer_pin(args.expected_signer_sha256) + if not re.fullmatch(r"[0-9a-fA-F]{40}", args.source_revision): + raise PackagingError("Source revision must be the full 40-character Git commit SHA.") + for path, description in ((args.apk, "Unsigned APK"), (args.keystore, "Signing keystore")): + regular_file(path, description) + password_file(args.store_password_file, "Keystore password file") + password_file(args.key_password_file, "Key password file") + tools = build_tools(args.build_tools_dir) + keytool = shutil.which("keytool") + if not keytool: + raise PackagingError("keytool from JDK 17 or later is required.") + if args.output_dir.exists() and (not args.output_dir.is_dir() or any(args.output_dir.iterdir())): + raise PackagingError("Output directory must be absent or empty; existing artifacts are never overwritten.") + try: + with zipfile.ZipFile(args.apk) as archive: + if archive.testzip() is not None or "AndroidManifest.xml" not in archive.namelist() or "classes.dex" not in archive.namelist(): + raise PackagingError("Unsigned APK is incomplete or corrupt.") + require_unsigned(args.apk, archive, tools["apksigner"]) + except zipfile.BadZipFile as error: + raise PackagingError("Unsigned APK is not a valid ZIP archive.") from error + require_metadata(apk_metadata(args.apk, tools["aapt"]), args) + signer = hashlib.sha256(certificate(args.keystore, args.key_alias, args.store_password_file, keytool)).hexdigest() + debug_certificate = run_tool([keytool, "-exportcert", "-keystore", str(ANDROID_DIR / "app/debug.keystore"), + "-alias", "androiddebugkey", "-storepass", "android"], "Public debug certificate inspection") + if signer == hashlib.sha256(debug_certificate).hexdigest(): + raise PackagingError("A beta must never use the repository's public debug signing key.") + if signer != expected_signer: + raise PackagingError("Keystore certificate does not match the expected signer pin.") + for name in ("mapping.txt", "seeds.txt"): + regular_file(args.r8_dir / name, "R8 output") + if not (args.r8_dir / name).stat().st_size: + raise PackagingError("R8 output must not be empty.") + # The keep checker resolves source paths from android/, as the existing CI step does. + run_tool(["sh", str(ANDROID_DIR / "tools/check-r8-output.sh"), str(args.r8_dir.resolve())], + "R8 runtime-keep verification", cwd=ANDROID_DIR) + build_inputs = verify_build_inputs(args) + args.output_dir.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".nodeterm-beta-", dir=args.output_dir.parent) as workspace: + temporary = Path(workspace) + artifacts = temporary / "artifacts" + artifacts.mkdir(mode=0o700) + aligned = temporary / "aligned.apk" + artifact_name = f"nodeterm-android-{args.version_name}.apk" + signed = artifacts / artifact_name + # apksigner consumes successive lines when the same password-file path supplies both + # passwords. Keep distinct private copies so a common one-line password file works too. + store_password = temporary / "store-password.txt" + key_password = temporary / "key-password.txt" + for source, destination in ((args.store_password_file, store_password), (args.key_password_file, key_password)): + destination.write_bytes(source.read_bytes()) + destination.chmod(0o600) + run_tool([str(tools["zipalign"]), "-P", "16", "-f", "4", str(args.apk), str(aligned)], "APK alignment") + # Inputs are normally downloaded into a stable local directory. Refuse accidental changes + # during packaging too, so the recorded hashes still describe the bytes we aligned. + if verify_build_inputs(args) != build_inputs: + raise PackagingError("Build inputs changed during APK alignment.") + run_tool([str(tools["apksigner"]), "sign", "--ks", str(args.keystore), "--ks-key-alias", args.key_alias, + "--ks-pass", "file:" + str(store_password), "--key-pass", "file:" + str(key_password), + "--v2-signing-enabled", "true", + "--v4-signing-enabled", "false", + "--out", str(signed), str(aligned)], "APK signing") + actual_signer = verified_signer(signed, tools["apksigner"]) + if actual_signer != expected_signer: + raise PackagingError("Signed APK certificate does not match the expected signer pin.") + require_metadata(apk_metadata(signed, tools["aapt"]), args) + run_tool([str(tools["zipalign"]), "-c", "-P", "16", "4", str(signed)], "Signed APK alignment verification") + if verify_build_inputs(args) != build_inputs: + raise PackagingError("Build inputs changed during APK signing.") + digest = sha256(signed) + (artifacts / (artifact_name + ".sha256")).write_text(f"{digest} {artifact_name}\n", encoding="utf-8") + metadata = {"schemaVersion": 1, **apk_metadata(signed, tools["aapt"]), "artifactName": artifact_name, + "signerSha256": actual_signer, "apkSha256": digest, "sourceRevision": args.source_revision.lower(), + "unsignedApkSha256": build_inputs["unsignedApkSha256"], "buildInputsSha256": build_inputs["buildInputsSha256"], + "r8MappingSha256": build_inputs["r8MappingSha256"], "r8SeedsSha256": build_inputs["r8SeedsSha256"]} + (artifacts / "beta-metadata.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8") + # All gates passed before the output becomes visible. A failed signing/verification leaves + # neither a misleading APK nor a partial set of artifacts to distribute. + os.replace(artifacts, args.output_dir) + return metadata + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for option in ("apk", "keystore", "store-password-file", "key-password-file", "r8-dir", "build-inputs", "output-dir"): + parser.add_argument("--" + option, type=Path, required=True) + parser.add_argument("--build-tools-dir", type=Path) + for option in ("key-alias", "expected-signer-sha256", "version-name", "source-revision"): + parser.add_argument("--" + option, required=True) + parser.add_argument("--version-code", type=int, required=True) + args = parser.parse_args() + try: + metadata = package_beta(args) + except (PackagingError, OSError, subprocess.TimeoutExpired, UnicodeError) as error: + print(f"package-beta: {error}", file=sys.stderr) + return 1 + print(f"Verified beta: {metadata['artifactName']} (version code {metadata['versionCode']}).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/android/tools/test_beta_sdk.py b/android/tools/test_beta_sdk.py new file mode 100644 index 0000000000..a847466bad --- /dev/null +++ b/android/tools/test_beta_sdk.py @@ -0,0 +1,67 @@ +"""Additional runner SDKs must not silently change the real-signature fixture toolchain.""" + +from pathlib import Path +import tempfile +import unittest + +from test_package_beta import fixture_sdk + + +class BetaFixtureSdkTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="nt-beta-sdk-selection-") + self.addCleanup(self.temp.cleanup) + self.sdk = Path(self.temp.name) + + def install(self, tools="36.0.0", platform="android-35"): + directory = self.sdk / "build-tools" / tools + directory.mkdir(parents=True, exist_ok=True) + for name in ("aapt", "apksigner", "zipalign", "d8"): + (directory / name).touch() + jar = self.sdk / "platforms" / platform / "android.jar" + jar.parent.mkdir(parents=True, exist_ok=True) + jar.touch() + return directory, jar + + def test_defaults_ignore_newer_lexical_and_preview_runner_installations(self): + expected = self.install() + for tools, platform in (("37.0.0", "android-37.2-beta3"), ("36.1.0", "android-36.1"), + ("9.0.0", "android-9"), ("36.0.0-rc1", "android-35-ext15")): + self.install(tools, platform) + self.assertEqual(expected, fixture_sdk(self.sdk)) + + def test_missing_pinned_tools_or_platform_fail_without_falling_back(self): + self.install("37.0.0", "android-37.2-beta3") + with self.assertRaisesRegex(RuntimeError, "build-tools 36.0.0"): + fixture_sdk(self.sdk) + tools, jar = self.install() + jar.unlink() + with self.assertRaisesRegex(RuntimeError, "platform android-35"): + fixture_sdk(self.sdk) + jar.touch() + (tools / "d8").unlink() + with self.assertRaisesRegex(RuntimeError, "build-tools 36.0.0"): + fixture_sdk(self.sdk) + + def test_explicit_reproduction_overrides_select_exact_tools_and_platform(self): + self.install() + expected = self.install("37.0.0", "android-37.2-beta3") + self.install("38.0.0", "android-38") + self.assertEqual(expected, fixture_sdk(self.sdk, "37.0.0", "android-37.2-beta3")) + + def test_invalid_or_absent_overrides_do_not_select_any_other_installation(self): + self.install() + for version in ("", "latest", "36.0.0-rc1", "../36.0.0", "36.0.0/anything"): + with self.subTest(version=version), self.assertRaises(RuntimeError): + fixture_sdk(self.sdk, version) + for platform in ("", "latest", "../android-35", "android-35/anything"): + with self.subTest(platform=platform), self.assertRaises(RuntimeError): + fixture_sdk(self.sdk, platform_version=platform) + with self.assertRaisesRegex(RuntimeError, "build-tools 37.0.0"): + fixture_sdk(self.sdk, "37.0.0") + with self.assertRaisesRegex(RuntimeError, "platform android-36"): + fixture_sdk(self.sdk, platform_version="android-36") + + +if __name__ == "__main__": + unittest.main() diff --git a/android/tools/test_beta_signer.py b/android/tools/test_beta_signer.py new file mode 100644 index 0000000000..ae69a08272 --- /dev/null +++ b/android/tools/test_beta_signer.py @@ -0,0 +1,122 @@ +"""Parse the official apksigner indexed and SDK-range certificate reports without relaxing gates.""" + +import os +from pathlib import Path +import runpy +import unittest + + +PACKAGER = runpy.run_path(os.environ.get("NODETERM_ANDROID_TEST_PACKAGER", str(Path(__file__).with_name("package-beta.py")))) +parse = PACKAGER["parse_verified_signer"] +PackagingError = PACKAGER["PackagingError"] +PIN = "a" * 64 +OTHER = "b" * 64 +V2 = "Verified using v2 scheme (APK Signature Scheme v2): true" +INDEXED = "Signer #1 certificate SHA-256 digest: " + PIN + + +def report(*certificates, count="1", v2=V2, separator="\n"): + lines = ["Verifies", v2] + if count is not None: + lines.append("Number of signers: " + count) + return separator.join(lines + list(certificates)) + separator + + +def ranged(minimum=28, maximum=2_147_483_647, pin=PIN, dev=False): + return (f"Signer (minSdkVersion={minimum}" + (" (dev release=true)" if dev else "") + + f", maxSdkVersion={maximum}) certificate SHA-256 digest: {pin}") + + +class BetaSignerOutputTest(unittest.TestCase): + def test_accepts_one_indexed_signer_and_line_endings(self): + for separator in ("\n", "\r\n"): + for count in ("1", None): + with self.subTest(separator=repr(separator), count=count): + self.assertEqual(PIN, parse(report(INDEXED, count=count, separator=separator))) + + def test_accepts_one_sdk_range_and_case_normalizes_its_pin(self): + for dev in (False, True): + self.assertEqual(PIN, parse(report(ranged(pin=PIN.upper(), dev=dev)))) + + def test_deduplicates_only_the_same_certificate_in_disjoint_sdk_ranges(self): + self.assertEqual(PIN, parse(report(ranged(33), ranged(28, 32)))) + self.assertEqual(PIN, parse(report(ranged(28, 32), ranged(33, 35), ranged(36)))) + + def test_accepts_build_tools_37_single_scheme_signer(self): + for label in ("V2 Signer:", "V3.0 Signer:"): + certificate = label + " certificate SHA-256 digest: " + PIN.upper() + self.assertEqual(PIN, parse(report(certificate))) + for count in (None, "0", "2", "01", "one", "1\nNumber of signers: 1"): + with self.subTest(label=label, count=count), self.assertRaises(PackagingError): + parse(report(certificate, count=count)) + for v2 in ("", V2.replace("true", "false")): + with self.subTest(label=label, v2=v2), self.assertRaises(PackagingError): + parse(report(certificate, v2=v2)) + + def test_accepts_build_tools_37_disjoint_same_certificate_scheme_ranges(self): + original = ranged(28, 32).replace("Signer (", "V3.0 Signer: (") + rotated = ranged(33).replace("Signer (", "V3.1 Signer: (") + self.assertEqual(PIN, parse(report(original, rotated))) + for certificates in ((original, rotated.replace(PIN, OTHER)), (rotated, rotated), + (original.replace("32)", "33)"), rotated)): + with self.subTest(certificates=certificates), self.assertRaises(PackagingError): + parse(report(*certificates)) + with self.assertRaises(PackagingError): + parse(report(original, rotated, count=None)) + + def test_rejects_duplicate_unknown_or_malformed_build_tools_37_signers(self): + good = "V3.0 Signer: certificate SHA-256 digest: " + PIN + for certificates in ((good, good), (good, good.replace(PIN, OTHER)), + (good, INDEXED), (good, good.replace("V3.0", "V2")), + (good, good.replace("V3.0", "V4")), + (good, ranged().replace("Signer (", "V3.2 Hybrid Classical Signer: (")), + (good, ranged().replace("Signer (", "V3.2 Hybrid PQC Signer: (")), + (good.replace("Signer:", "Signer #1:"),), + (good.replace("Signer:", "Signer #2:"),), + (good.replace("V3.0", "V3.1"),), + (good.replace("V3.0", "V3.2 Hybrid Classical"),), + (good.replace("V3.0", "V3.2 Hybrid PQC"),), + (good.replace(PIN, PIN[:-1]),), (good + " trailing",)): + with self.subTest(certificates=certificates), self.assertRaises(PackagingError): + parse(report(*certificates)) + + def test_rejects_missing_or_unverified_v2(self): + for v2 in ("", V2.replace("true", "false"), V2 + " trailing", V2 + "\n" + V2, + V2 + "\n" + V2.replace("true", "false")): + with self.subTest(v2=v2), self.assertRaises(PackagingError): + parse(report(INDEXED, v2=v2)) + + def test_rejects_multiple_signers_and_malformed_or_conflicting_counts(self): + for count in ("0", "2", "01", "one", "", "1 trailing", "1\nNumber of signers: 1", "1\nNumber of signers: 2"): + with self.subTest(count=count), self.assertRaises(PackagingError): + parse(report(INDEXED, count=count)) + for certs in ((INDEXED, INDEXED.replace("#1", "#2")), (INDEXED, INDEXED), + (ranged(28, 32), ranged(33, pin=OTHER)), (INDEXED, ranged())): + with self.subTest(certs=certs), self.assertRaises(PackagingError): + parse(report(*certs)) + + def test_rejects_overlapping_repeated_and_invalid_sdk_ranges(self): + cases = [(ranged(28, 33), ranged(33)), (ranged(), ranged()), + (ranged(28, 33), ranged(33, dev=True)), (ranged(28, 40), ranged(30, 35)), + (ranged(0),), (ranged(-1),), (ranged(33, 32),), (ranged(maximum=2_147_483_648),), + (ranged("028"),), (ranged("1" * 5000),)] + for certs in cases: + with self.subTest(certs=str(certs)[:150]), self.assertRaises(PackagingError): + parse(report(*certs)) + + def test_rejects_unrecognized_labels_and_missing_or_malformed_fingerprints(self): + for cert in ("", INDEXED.replace("#1", "#2"), INDEXED.replace("#1", "#01"), + INDEXED.replace(PIN, PIN[:-1]), INDEXED + " trailing", INDEXED.replace("digest:", "digest"), + INDEXED.replace(PIN, "g" * 64), ranged().replace("maxSdkVersion", "maximumSdkVersion"), + ranged().replace("Signer (", "Signer #1 (")): + with self.subTest(cert=cert), self.assertRaises(PackagingError): + parse(report(cert)) + + def test_ignores_source_stamp_certificate_when_counting_apk_signers(self): + self.assertEqual(PIN, parse(report(INDEXED, "Source Stamp Signer certificate SHA-256 digest: " + OTHER))) + self.assertEqual(PIN, parse(report("V3.0 Signer: certificate SHA-256 digest: " + PIN, + "Source Stamp Signer: certificate SHA-256 digest: " + OTHER))) + + +if __name__ == "__main__": + unittest.main() diff --git a/android/tools/test_beta_version.py b/android/tools/test_beta_version.py new file mode 100644 index 0000000000..aaaaaf9d2a --- /dev/null +++ b/android/tools/test_beta_version.py @@ -0,0 +1,29 @@ +"""The real CI preflight accepts ordinary defaults and refuses unusable beta inputs.""" + +import os +from pathlib import Path +import subprocess +import sys +import unittest + + +class BetaVersionEnvironmentTest(unittest.TestCase): + def test_selected_environment_values_match_packaging_rules(self): + cases = [ + (None, None, True), ("", "", True), + ("2", "0.1.0-beta.1", True), ("2100000000", "9.9.9-beta.8", True), + ("0", "0.1.0-beta.1", False), ("2100000001", "0.1.0-beta.1", False), + ("2", "0.1.0", False), ("2", "0.1.0-beta.0", False), + ("02", "0.1.0-beta.1", False), (" 2", "0.1.0-beta.1", False), + ("2", None, False), (None, "0.1.0-beta.1", False), + ] + script = Path(__file__).with_name("check-beta-version.py") + for code, name, accepted in cases: + with self.subTest(code=code, name=name): + env = os.environ.copy() + for key, value in (("NODETERM_ANDROID_VERSION_CODE", code), ("NODETERM_ANDROID_VERSION_NAME", name)): + env.pop(key, None) + if value is not None: + env[key] = value + result = subprocess.run([sys.executable, str(script)], env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(accepted, result.returncode == 0, result.stderr) diff --git a/android/tools/test_package_beta.py b/android/tools/test_package_beta.py new file mode 100644 index 0000000000..2b7fdd96d0 --- /dev/null +++ b/android/tools/test_package_beta.py @@ -0,0 +1,418 @@ +"""Real SDK/signature gates against tiny test APKs, never a nodeterm application build. + +Set ANDROID_HOME (or NODETERM_ANDROID_TEST_SDK) to a cached SDK with build-tools and android.jar. +Every signing key created here is temporary and for tests only. No phone, sockets or network. +""" + +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile + + +HERE = Path(__file__).resolve().parent +PACKAGER = Path(os.environ.get("NODETERM_ANDROID_TEST_PACKAGER", str(HERE / "package-beta.py"))) +ANDROID_DIR = HERE.parent +VERSION_NAME = "0.1.0-beta.1" +REVISION = "a" * 40 + + +def fixture_sdk(sdk, build_tools_version="36.0.0", platform_version="android-35"): + """Use the SDK explicitly installed by CI; additional runner SDKs must not change the fixture.""" + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", build_tools_version): + raise RuntimeError("Fixture build-tools override must name one stable SDK version.") + if not re.fullmatch(r"android-[0-9]+(?:\.[0-9]+)?(?:-beta[1-9][0-9]*)?", platform_version): + raise RuntimeError("Fixture platform override must name one Android SDK platform.") + tools = Path(sdk) / "build-tools" / build_tools_version + android_jar = Path(sdk) / "platforms" / platform_version / "android.jar" + if not all((tools / name).is_file() for name in ("aapt", "apksigner", "zipalign", "d8")): + raise RuntimeError(f"Fixture requires Android build-tools {build_tools_version}: {tools}") + if not android_jar.is_file(): + raise RuntimeError(f"Fixture requires Android SDK platform {platform_version}: {android_jar}") + return tools, android_jar + + +class PackageBetaTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + sdk = os.environ.get("NODETERM_ANDROID_TEST_SDK") or os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") + if not sdk: + raise RuntimeError("An Android SDK is required for real APK/signature gate tests.") + cls.sdk = Path(sdk) + cls.tools, cls.android_jar = fixture_sdk( + cls.sdk, os.environ.get("NODETERM_ANDROID_TEST_BUILD_TOOLS", "36.0.0"), + os.environ.get("NODETERM_ANDROID_TEST_PLATFORM", "android-35")) + print(f"Packaging fixture SDK: build-tools={cls.tools}, platform={cls.android_jar}", file=sys.stderr) + if not shutil.which("keytool") or not shutil.which("javac"): + raise RuntimeError("SDK build tools/platform and JDK keytool/javac are required.") + cls.temp = tempfile.TemporaryDirectory(prefix="nt-beta-tests-") + cls.root = Path(cls.temp.name) + cls.password = cls.root / "password.txt" + cls.password.write_text("ephemeral-test-password\n", encoding="utf-8") + cls.password.chmod(0o600) + cls.key_password = cls.root / "key-password.txt" + cls.key_password.write_bytes(cls.password.read_bytes()) + cls.key_password.chmod(0o600) + cls.keystore = cls.root / "fixture-private.p12" + cls.tool(["keytool", "-genkeypair", "-alias", "fixture", "-keyalg", "RSA", "-keysize", "2048", + "-validity", "2", "-dname", "CN=Temporary Android packaging test", "-storetype", "PKCS12", + "-keystore", str(cls.keystore), "-storepass:file", str(cls.password), "-keypass:file", str(cls.password)]) + cls.pin = cls.export_pin(cls.keystore, "fixture", cls.password) + # A valid fixture dex, rather than a file that merely has the expected name. The fixture + # has no app behavior: aapt creates only its manifest, and the class holds one constant. + java = cls.root / "Fixture.java" + java.write_text("public class Fixture { public static final int VALUE = 1; }\n", encoding="utf-8") + cls.tool(["javac", "--release", "17", str(java)]) + dex_dir = cls.root / "dex" + dex_dir.mkdir() + cls.tool([str(cls.tools / "d8"), "--min-api", "26", "--output", str(dex_dir), str(cls.root / "Fixture.class")]) + cls.dex = (dex_dir / "classes.dex").read_bytes() + cls.good_apk = cls.fixture_apk("good") + cls.good_r8 = cls.fixture_r8(cls.root / "r8") + cls.good_inputs = cls.fixture_inputs("good-inputs") + cls.signature_diagnostics = None + + @classmethod + def tearDownClass(cls): + if hasattr(cls, "temp"): + cls.temp.cleanup() + + @classmethod + def tool(cls, argv): + result = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120) + if result.returncode: + raise AssertionError(f"Test fixture tool failed: {Path(argv[0]).name}: {result.stderr.decode(errors='replace')}") + return result.stdout + + @classmethod + def export_pin(cls, keystore, alias, password): + der = cls.tool(["keytool", "-exportcert", "-keystore", str(keystore), "-alias", alias, + "-storepass:file", str(password)]) + return hashlib.sha256(der).hexdigest() + + @classmethod + def fixture_apk(cls, name, package="dev.nodeterm.android", code=2, version=VERSION_NAME, debug=False, minimum=26, target=35): + root = cls.root / name + root.mkdir() + manifest = root / "AndroidManifest.xml" + manifest.write_text(f''' + + + ''', encoding="utf-8") + apk = root / "fixture-unsigned.apk" + cls.tool([str(cls.tools / "aapt"), "package", "-f", "-M", str(manifest), "-I", str(cls.android_jar), "-F", str(apk)]) + with zipfile.ZipFile(apk, "a") as archive: + archive.writestr("classes.dex", cls.dex) + return apk + + @classmethod + def fixture_r8(cls, directory): + directory.mkdir() + bridge = "dev.nodeterm.android.ui.TerminalController$Bridge" + source = (ANDROID_DIR / "app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt").read_text() + methods = re.findall(r"@JavascriptInterface\s+fun\s+(\w+)", source) + if not methods: + raise AssertionError("R8 fixture must pin the real bridge's kept methods") + workers = ["dev.nodeterm.android.notify.InboxWorker", "dev.nodeterm.android.notify.InboxActionWorker"] + classes = workers + ["org.bouncycastle.jce.provider.BouncyCastleProvider", + "org.bouncycastle.jcajce.provider.symmetric.AES$Mappings", + "org.bouncycastle.jcajce.provider.asymmetric.edec.KeyAgreementSpi$X25519", + "dev.nodeterm.protocol.host.HostException"] + seeds = [f"{bridge}: void {method}()" for method in methods] + seeds += [f"{worker}: void (android.content.Context, androidx.work.WorkerParameters)" for worker in workers] + seeds += classes + (directory / "seeds.txt").write_text("\n".join(seeds) + "\n", encoding="utf-8") + # These are synthetic R8 output fixtures for the keep checker, never output from a nodeterm + # build and never claims that the fixture APK was minified by AGP. + (directory / "mapping.txt").write_text("\n".join(f"{name} -> {name}:" for name in classes) + "\n", encoding="utf-8") + return directory + + @classmethod + def fixture_inputs(cls, name, apk=None, r8=None, code=2, version=VERSION_NAME, **overrides): + apk, r8 = apk or cls.good_apk, r8 or cls.good_r8 + metadata = {"schemaVersion": 1, "signed": False, "sourceRevision": REVISION, + "versionCode": code, "versionName": version, + "unsignedApkSha256": hashlib.sha256(apk.read_bytes()).hexdigest(), + "r8MappingSha256": hashlib.sha256((r8 / "mapping.txt").read_bytes()).hexdigest(), + "r8SeedsSha256": hashlib.sha256((r8 / "seeds.txt").read_bytes()).hexdigest()} + metadata.update(overrides) + path = cls.root / (name + ".json") + path.write_text(json.dumps(metadata), encoding="utf-8") + return path + + def setUp(self): + self.output = self.root / (self.id().split(".")[-1] + "-output") + self.call_number = 0 + + def package(self, **overrides): + values = {"apk": self.good_apk, "keystore": self.keystore, "store-password-file": self.password, + "key-password-file": self.password, "key-alias": "fixture", "expected-signer-sha256": self.pin, + "version-code": 2, "version-name": VERSION_NAME, "source-revision": REVISION, + "r8-dir": self.good_r8, + "output-dir": self.output, "build-tools-dir": self.tools} + values.update(overrides) + self.call_number += 1 + if "build-inputs" not in values: + if all((values["r8-dir"] / report).is_file() for report in ("mapping.txt", "seeds.txt")): + values["build-inputs"] = self.fixture_inputs(self.id().split(".")[-1] + "-inputs-" + str(self.call_number), + apk=values["apk"], r8=values["r8-dir"], + code=values["version-code"], version=values["version-name"]) + else: + values["build-inputs"] = self.good_inputs + argv = [sys.executable, str(PACKAGER)] + for key, value in values.items(): + argv += ["--" + key, str(value)] + result = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120) + if result.returncode and b"APK must have one verified signer" in result.stderr: + # Production deliberately suppresses signing-tool output. Diagnose this test's + # selected SDK with a new disposable fixture, never a real app key or password. + result.stderr += self.fixture_signature_diagnostics() + return result + + @classmethod + def fixture_signature_diagnostics(cls): + if cls.signature_diagnostics is None: + aligned, signed = cls.root / "diagnostic-aligned.apk", cls.root / "diagnostic-signed.apk" + cls.tool([str(cls.tools / "zipalign"), "-P", "16", "-f", "4", str(cls.good_apk), str(aligned)]) + cls.tool([str(cls.tools / "apksigner"), "sign", "--ks", str(cls.keystore), "--ks-key-alias", "fixture", + "--ks-pass", "file:" + str(cls.password), "--key-pass", "file:" + str(cls.key_password), + "--v2-signing-enabled", "true", "--v4-signing-enabled", "false", + "--out", str(signed), str(aligned)]) + report = subprocess.run([str(cls.tools / "apksigner"), "verify", "--verbose", "--print-certs", str(signed)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120) + header = (f"\nDisposable test fixture signature diagnostics:\n" + f"build-tools={cls.tools}\nplatform={cls.android_jar}\nverify exit={report.returncode}\n") + cls.signature_diagnostics = header.encode() + report.stdout + report.stderr + return cls.signature_diagnostics + + def refused(self, result, message): + self.assertNotEqual(0, result.returncode, result.stdout.decode() + result.stderr.decode()) + self.assertIn(message, result.stderr.decode()) + self.assertFalse(self.output.exists(), "a failed gate must publish no artifacts") + self.assertNotIn("ephemeral-test-password", result.stdout.decode() + result.stderr.decode()) + + def signer_wrapper(self, name, after_sign="", before_sign=""): + directory = self.root / name + directory.mkdir() + for tool in ("aapt", "zipalign"): + (directory / tool).symlink_to(self.tools / tool) + wrapper = directory / "apksigner" + wrapper.write_text(f'''#!{sys.executable} +import json +import struct +import subprocess +import sys +from pathlib import Path +import zipfile +arguments = sys.argv[1:] +if arguments and arguments[0] == "sign": +{before_sign or " pass"} +result = subprocess.run([{str(self.tools / "apksigner")!r}] + arguments) +if result.returncode == 0 and arguments and arguments[0] == "sign": +{after_sign or " pass"} +sys.exit(result.returncode) +''', encoding="utf-8") + wrapper.chmod(0o755) + return directory + + def test_accepts_verified_private_beta_and_writes_exact_artifacts(self): + result = self.package() + self.assertEqual(0, result.returncode, result.stderr.decode()) + name = "nodeterm-android-" + VERSION_NAME + ".apk" + self.assertEqual({name, name + ".sha256", "beta-metadata.json"}, {path.name for path in self.output.iterdir()}) + self.assertEqual(0, self.output.stat().st_mode & 0o077, "private APK output must be inaccessible to other users") + apk = self.output / name + digest = hashlib.sha256(apk.read_bytes()).hexdigest() + self.assertEqual(f"{digest} {name}\n", (self.output / (name + ".sha256")).read_text()) + metadata = json.loads((self.output / "beta-metadata.json").read_text()) + self.assertEqual(self.pin, metadata["signerSha256"]) + self.assertEqual(digest, metadata["apkSha256"]) + self.assertEqual(2, metadata["versionCode"]) + self.assertEqual(False, metadata["debuggable"]) + self.assertEqual(REVISION, metadata["sourceRevision"]) + self.assertEqual(hashlib.sha256((self.good_r8 / "mapping.txt").read_bytes()).hexdigest(), metadata["r8MappingSha256"]) + self.assertEqual(hashlib.sha256(self.good_apk.read_bytes()).hexdigest(), metadata["unsignedApkSha256"]) + inputs = self.root / (self.id().split(".")[-1] + "-inputs-1.json") + self.assertEqual(hashlib.sha256(inputs.read_bytes()).hexdigest(), metadata["buildInputsSha256"]) + output = self.tool([str(self.tools / "apksigner"), "verify", "--verbose", "--print-certs", str(apk)]).decode() + self.assertIn(self.pin, output) + self.assertRegex(output, r"Verified using v2 scheme .*: true") + + def test_next_beta_keeps_signer_and_increases_version(self): + first = self.package() + self.assertEqual(0, first.returncode, first.stderr.decode()) + first_metadata = json.loads((self.output / "beta-metadata.json").read_text()) + self.output = self.output.with_name(self.output.name + "-next") + next_apk = self.fixture_apk("next-beta", code=3, version="0.1.0-beta.2") + inputs = self.fixture_inputs("next-beta-inputs", apk=next_apk, code=3, version="0.1.0-beta.2") + second = self.package(**{"apk": next_apk, "version-code": 3, "version-name": "0.1.0-beta.2", "build-inputs": inputs}) + self.assertEqual(0, second.returncode, second.stderr.decode()) + second_metadata = json.loads((self.output / "beta-metadata.json").read_text()) + self.assertEqual(first_metadata["signerSha256"], second_metadata["signerSha256"]) + self.assertGreater(second_metadata["versionCode"], first_metadata["versionCode"]) + + def test_requires_v2_even_when_the_signing_tools_default_to_disabled(self): + wrapper = self.signer_wrapper("default-v2-disabled", before_sign=''' if "--v2-signing-enabled" not in arguments: + arguments[1:1] = ["--v2-signing-enabled", "false"]''') + result = self.package(**{"build-tools-dir": wrapper}) + self.assertEqual(0, result.returncode, result.stderr.decode()) + apk = self.output / ("nodeterm-android-" + VERSION_NAME + ".apk") + output = self.tool([str(self.tools / "apksigner"), "verify", "--verbose", "--print-certs", str(apk)]).decode() + self.assertIn(self.pin, output) + self.assertRegex(output, r"Verified using v2 scheme .*: true") + + def test_rejects_debuggable_apk(self): + self.refused(self.package(apk=self.fixture_apk("debuggable", debug=True)), "debuggable") + + def test_rejects_wrong_package(self): + self.refused(self.package(apk=self.fixture_apk("wrong-package", package="dev.other.app")), "packageName") + + def test_rejects_wrong_version_code(self): + self.refused(self.package(apk=self.fixture_apk("wrong-code", code=3)), "versionCode") + + def test_rejects_wrong_version_name(self): + self.refused(self.package(apk=self.fixture_apk("wrong-name", version="0.1.0-beta.2")), "versionName") + + def test_rejects_wrong_sdk_levels(self): + for name, levels, field in (("minimum", {"minimum": 25}, "minSdk"), ("target", {"target": 34}, "targetSdk")): + self.refused(self.package(apk=self.fixture_apk("wrong-sdk-" + name, **levels)), field) + + def test_rejects_public_debug_key_even_if_the_expected_pin_matches(self): + password = self.root / "debug-password.txt" + password.write_text("android\n") + copied = self.root / "copied-debug.keystore" + shutil.copyfile(ANDROID_DIR / "app/debug.keystore", copied) + debug_pin = self.export_pin(copied, "androiddebugkey", password) + self.refused(self.package(**{"keystore": copied, "key-alias": "androiddebugkey", "store-password-file": password, + "key-password-file": password, "expected-signer-sha256": debug_pin}), "public debug") + + def test_rejects_wrong_signer_pin(self): + self.refused(self.package(**{"expected-signer-sha256": "0" * 64}), "signer pin") + + def test_rejects_unexpected_signer_in_signed_output(self): + store_password = self.root / "wrapper-store-password.txt" + key_password = self.root / "wrapper-key-password.txt" + store_password.write_text("android\n") + key_password.write_text("android\n") + replacements = {"--ks": str(ANDROID_DIR / "app/debug.keystore"), "--ks-key-alias": "androiddebugkey", + "--ks-pass": "file:" + str(store_password), "--key-pass": "file:" + str(key_password)} + wrapper = self.signer_wrapper("wrong-output-signer", before_sign=f''' replacements = {replacements!r} + for option, value in replacements.items(): + arguments[arguments.index(option) + 1] = value''') + self.refused(self.package(**{"build-tools-dir": wrapper}), "Signed APK certificate") + + def test_rejects_tampered_signed_output(self): + # Change an uncompressed dex byte after the real SDK signer succeeds. Manifest and ZIP + # alignment remain readable, so rejection proves the signature gate examines final bytes. + wrapper = self.signer_wrapper("tampered-output", after_sign=''' output = Path(arguments[arguments.index("--out") + 1]) + with zipfile.ZipFile(output) as archive: + info = archive.getinfo("classes.dex") + assert info.compress_type == zipfile.ZIP_STORED + with output.open("r+b") as apk: + apk.seek(info.header_offset + 26) + name_length, extra_length = struct.unpack(" org.bouncycastle.jce.provider.BouncyCastleProvider:\n", "")) + self.refused(self.package(**{"r8-dir": r8}), "R8 runtime-keep verification failed") + seeds.unlink() + self.refused(self.package(**{"r8-dir": r8}), "R8 output is not a file") + + def test_preserves_existing_output(self): + self.output.mkdir() + before = self.output / "previous.apk" + before.write_bytes(b"existing artifact") + result = self.package() + self.assertNotEqual(0, result.returncode) + self.assertIn("never overwritten", result.stderr.decode()) + self.assertEqual(b"existing artifact", before.read_bytes()) + self.assertEqual({before.name}, {path.name for path in self.output.iterdir()}) + + def test_rejects_invalid_versions_before_creating_output(self): + for overrides in ({"version-code": 0}, {"version-code": 2_100_000_001}, + {"version-name": "0.1.0"}, {"version-name": "0.1.0-beta.1/elsewhere"}): + self.refused(self.package(**overrides), "Version") + + def test_rejects_incomplete_apk(self): + incomplete = self.root / "incomplete.apk" + with zipfile.ZipFile(incomplete, "w") as archive: + archive.writestr("AndroidManifest.xml", b"incomplete fixture") + self.refused(self.package(apk=incomplete), "incomplete or corrupt") + + def test_rejects_build_inputs_from_a_different_apk(self): + self.refused(self.package(**{"apk": self.fixture_apk("other-build"), "build-inputs": self.good_inputs}), + "Build inputs metadata does not match") + + def test_rejects_build_inputs_from_different_r8_reports(self): + r8 = self.fixture_r8(self.root / "other-build-r8") + for report in ("mapping.txt", "seeds.txt"): + path = r8 / report + original = path.read_text() + path.write_text(original + "# Different build report\n") + self.refused(self.package(**{"r8-dir": r8, "build-inputs": self.good_inputs}), "Build inputs metadata does not match") + path.write_text(original) + + def test_rejects_build_inputs_from_different_revision_or_version(self): + for number, fields in enumerate(({"sourceRevision": "b" * 40}, {"versionCode": 3}, {"versionName": "0.1.0-beta.2"}, + {"signed": True}, {"signed": 0}, {"schemaVersion": 2}, {"versionCode": "2"})): + inputs = self.fixture_inputs("mismatched-inputs-" + str(number), **fields) + self.refused(self.package(**{"build-inputs": inputs}), "Build inputs metadata does not match") + + +if __name__ == "__main__": + unittest.main() diff --git a/android/tools/typecheck/README.md b/android/tools/typecheck/README.md new file mode 100644 index 0000000000..0db707a01e --- /dev/null +++ b/android/tools/typecheck/README.md @@ -0,0 +1,35 @@ +# Offline type-check for the Android app + +This is a stopgap for sandboxes that cannot reach Google Maven (`dl.google.com` / `maven.google.com`), +where the Android Gradle Plugin (AGP) cannot resolve. It compiles `android/app/src/main/kotlin` as +plain Kotlin/JVM against: + +- `org.robolectric:android-all` (the Android framework classes, from Maven Central), +- Compose Multiplatform **desktop** artifacts (the same Compose APIs, from Maven Central), and +- hand-written stubs in `stubs/` for the androidx / zxing classes that exist only on Google Maven + (activity-compose, lifecycle-runtime-compose, work, core, the zxing scanner contract, and `R`). + +```bash +cd android/tools/typecheck +gradle compileKotlin # needs Gradle 8.x on PATH; no wrapper here on purpose +``` + +The JDK has to be at least 17 (the build targets 17) and one your Gradle can run on: JDK 17–24 for +Gradle 8.14.x, the version the wrapper in `android/` pins. JDK 25 needs Gradle 9.1 or newer. + +What it catches: Kotlin compile errors, wrong imports, API misuse visible in signatures. + +What it cannot catch: anything aapt2, the manifest merger, R8/D8 or a device would catch — +missing resources, manifest errors, Android-only runtime rules (e.g. `NetworkOnMainThreadException`), +API-level availability, and any difference between Compose Multiplatform desktop and Jetpack Compose +for Android. The real build is `./gradlew :app:assembleDebug` in `android/`, which CI runs +(`.github/workflows/android.yml`). + +Its dependency pins mirror the app's (the Kotlin plugin, BouncyCastle) or stand in for them (Compose +Multiplatform desktop for Jetpack Compose, robolectric's `android-all` for `compileSdk` 35), so they are +moved by hand when the app's move. Dependabot deliberately does not watch this build +(`.github/dependabot.yml` says why): its PRs would move them to whatever is newest instead. + +When the app starts using a new androidx/Google-Maven-only class, add a stub with the same +package, name and the signatures the app calls. `stubs/java/dev/nodeterm/android/R.java` must list +every `R.*` id the Kotlin references. diff --git a/android/tools/typecheck/build.gradle.kts b/android/tools/typecheck/build.gradle.kts new file mode 100644 index 0000000000..39c4aa927d --- /dev/null +++ b/android/tools/typecheck/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + kotlin("jvm") version "2.2.0" + id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" + java +} +java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } +kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } +sourceSets { + main { + kotlin.srcDirs("../../app/src/main/kotlin", "stubs/kotlin") + java.srcDirs("stubs/java") + } +} +dependencies { + implementation("dev.nodeterm:protocol:0.1.0") + compileOnly("org.robolectric:android-all:15-robolectric-13954326") + implementation("org.jetbrains.compose.runtime:runtime-desktop:1.8.2") + implementation("org.jetbrains.compose.ui:ui-desktop:1.8.2") + implementation("org.jetbrains.compose.foundation:foundation-desktop:1.8.2") + implementation("org.jetbrains.compose.material3:material3-desktop:1.8.2") + implementation("org.jetbrains.compose.material:material-icons-core-desktop:1.7.3") + implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") +} +configurations.all { + exclude(group = "androidx.lifecycle") + exclude(group = "androidx.annotation") + exclude(group = "androidx.collection") + exclude(group = "androidx.arch.core") +} diff --git a/android/tools/typecheck/settings.gradle.kts b/android/tools/typecheck/settings.gradle.kts new file mode 100644 index 0000000000..5f1ab260aa --- /dev/null +++ b/android/tools/typecheck/settings.gradle.kts @@ -0,0 +1,4 @@ +pluginManagement { repositories { mavenCentral(); gradlePluginPortal() } } +dependencyResolutionManagement { repositories { mavenCentral() } } +rootProject.name = "appcheck" +includeBuild("../../protocol") diff --git a/android/tools/typecheck/stubs/java/androidx/activity/ComponentActivity.java b/android/tools/typecheck/stubs/java/androidx/activity/ComponentActivity.java new file mode 100644 index 0000000000..908a734b20 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/activity/ComponentActivity.java @@ -0,0 +1,9 @@ +package androidx.activity; +import android.content.Intent; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContract; +public class ComponentActivity extends android.app.Activity { + public final ActivityResultLauncher registerForActivityResult(ActivityResultContract contract, ActivityResultCallback callback) { return null; } + @Override protected void onNewIntent(Intent intent) {} +} diff --git a/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultCallback.java b/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultCallback.java new file mode 100644 index 0000000000..847b6b323d --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultCallback.java @@ -0,0 +1,2 @@ +package androidx.activity.result; +public interface ActivityResultCallback { void onActivityResult(O result); } diff --git a/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultLauncher.java b/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultLauncher.java new file mode 100644 index 0000000000..dcdd5a6fa7 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/activity/result/ActivityResultLauncher.java @@ -0,0 +1,2 @@ +package androidx.activity.result; +public abstract class ActivityResultLauncher { public void launch(I input) {} } diff --git a/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContract.java b/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContract.java new file mode 100644 index 0000000000..7fd0e96385 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContract.java @@ -0,0 +1,2 @@ +package androidx.activity.result.contract; +public abstract class ActivityResultContract {} diff --git a/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContracts.java b/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContracts.java new file mode 100644 index 0000000000..9ccb26aec3 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/activity/result/contract/ActivityResultContracts.java @@ -0,0 +1,4 @@ +package androidx.activity.result.contract; +public final class ActivityResultContracts { + public static final class RequestPermission extends ActivityResultContract {} +} diff --git a/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java new file mode 100644 index 0000000000..3cf1087703 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationCompat.java @@ -0,0 +1,37 @@ +package androidx.core.app; +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +public class NotificationCompat { + public static final int VISIBILITY_PUBLIC = Notification.VISIBILITY_PUBLIC; + public static final int VISIBILITY_PRIVATE = Notification.VISIBILITY_PRIVATE; + public static final int VISIBILITY_SECRET = Notification.VISIBILITY_SECRET; + public abstract static class Style {} + public static class BigTextStyle extends Style { public BigTextStyle bigText(CharSequence cs) { return this; } } + public static class Action { + public static final class Builder { + public Builder(int icon, CharSequence title, PendingIntent intent) {} + public Builder setAuthenticationRequired(boolean authenticationRequired) { return this; } + public Action build() { return null; } + } + } + public static class Builder { + public Builder(Context context, String channelId) {} + public Builder setSmallIcon(int icon) { return this; } + public Builder setContentTitle(CharSequence title) { return this; } + public Builder setContentText(CharSequence text) { return this; } + public Builder setStyle(Style style) { return this; } + public Builder setSubText(CharSequence text) { return this; } + public Builder setWhen(long when) { return this; } + public Builder setAutoCancel(boolean autoCancel) { return this; } + public Builder setContentIntent(PendingIntent intent) { return this; } + public Builder setVisibility(int visibility) { return this; } + public Builder setPublicVersion(Notification n) { return this; } + public Builder addAction(Action action) { return this; } + public Builder setOnlyAlertOnce(boolean onlyAlertOnce) { return this; } + public Builder setSilent(boolean silent) { return this; } + public Builder setTimeoutAfter(long durationMs) { return this; } + public Builder setProgress(int max, int progress, boolean indeterminate) { return this; } + public Notification build() { return null; } + } +} diff --git a/android/tools/typecheck/stubs/java/androidx/core/app/NotificationManagerCompat.java b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationManagerCompat.java new file mode 100644 index 0000000000..fd0e2bb25d --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/core/app/NotificationManagerCompat.java @@ -0,0 +1,8 @@ +package androidx.core.app; +import android.app.Notification; +import android.content.Context; +public final class NotificationManagerCompat { + public static NotificationManagerCompat from(Context context) { return null; } + public void notify(int id, Notification notification) {} + public boolean areNotificationsEnabled() { return true; } +} diff --git a/android/tools/typecheck/stubs/java/androidx/core/content/ContextCompat.java b/android/tools/typecheck/stubs/java/androidx/core/content/ContextCompat.java new file mode 100644 index 0000000000..4feebd0ab5 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/core/content/ContextCompat.java @@ -0,0 +1,3 @@ +package androidx.core.content; +import android.content.Context; +public class ContextCompat { public static int checkSelfPermission(Context context, String permission) { return 0; } } diff --git a/android/tools/typecheck/stubs/java/androidx/work/Constraints.java b/android/tools/typecheck/stubs/java/androidx/work/Constraints.java new file mode 100644 index 0000000000..2ced3d2be0 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/Constraints.java @@ -0,0 +1,4 @@ +package androidx.work; +public final class Constraints { + public static final class Builder { public Builder setRequiredNetworkType(NetworkType t) { return this; } public Constraints build() { return null; } } +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/Data.java b/android/tools/typecheck/stubs/java/androidx/work/Data.java new file mode 100644 index 0000000000..bde2e4912f --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/Data.java @@ -0,0 +1,8 @@ +package androidx.work; +public final class Data { + public String getString(String key) { return null; } + public static final class Builder { + public Builder putString(String key, String value) { return this; } + public Data build() { return null; } + } +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/ExistingPeriodicWorkPolicy.java b/android/tools/typecheck/stubs/java/androidx/work/ExistingPeriodicWorkPolicy.java new file mode 100644 index 0000000000..8a7a61bc46 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/ExistingPeriodicWorkPolicy.java @@ -0,0 +1,2 @@ +package androidx.work; +public enum ExistingPeriodicWorkPolicy { KEEP, UPDATE, CANCEL_AND_REENQUEUE } diff --git a/android/tools/typecheck/stubs/java/androidx/work/ExistingWorkPolicy.java b/android/tools/typecheck/stubs/java/androidx/work/ExistingWorkPolicy.java new file mode 100644 index 0000000000..aa6ca24704 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/ExistingWorkPolicy.java @@ -0,0 +1,2 @@ +package androidx.work; +public enum ExistingWorkPolicy { REPLACE, KEEP, APPEND, APPEND_OR_REPLACE } diff --git a/android/tools/typecheck/stubs/java/androidx/work/ForegroundInfo.java b/android/tools/typecheck/stubs/java/androidx/work/ForegroundInfo.java new file mode 100644 index 0000000000..6998cf43ae --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/ForegroundInfo.java @@ -0,0 +1,5 @@ +package androidx.work; +import android.app.Notification; +public final class ForegroundInfo { + public ForegroundInfo(int notificationId, Notification notification) {} +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/ListenableWorker.java b/android/tools/typecheck/stubs/java/androidx/work/ListenableWorker.java new file mode 100644 index 0000000000..c974d1bdaf --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/ListenableWorker.java @@ -0,0 +1,9 @@ +package androidx.work; +import android.content.Context; +public abstract class ListenableWorker { + public ListenableWorker(Context appContext, WorkerParameters workerParams) {} + public final Context getApplicationContext() { return null; } + public final Data getInputData() { return null; } + public final int getRunAttemptCount() { return 0; } + public abstract static class Result { public static Result success() { return null; } public static Result retry() { return null; } } +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/NetworkType.java b/android/tools/typecheck/stubs/java/androidx/work/NetworkType.java new file mode 100644 index 0000000000..8703497741 --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/NetworkType.java @@ -0,0 +1,2 @@ +package androidx.work; +public enum NetworkType { NOT_REQUIRED, CONNECTED, UNMETERED } diff --git a/android/tools/typecheck/stubs/java/androidx/work/OneTimeWorkRequest.java b/android/tools/typecheck/stubs/java/androidx/work/OneTimeWorkRequest.java new file mode 100644 index 0000000000..f18cd2668e --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/OneTimeWorkRequest.java @@ -0,0 +1,9 @@ +package androidx.work; +public final class OneTimeWorkRequest { + public static final class Builder { + public Builder(Class workerClass) {} + public Builder setExpedited(OutOfQuotaPolicy policy) { return this; } + public Builder setInputData(Data inputData) { return this; } + public OneTimeWorkRequest build() { return null; } + } +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/OutOfQuotaPolicy.java b/android/tools/typecheck/stubs/java/androidx/work/OutOfQuotaPolicy.java new file mode 100644 index 0000000000..3c36d2742b --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/OutOfQuotaPolicy.java @@ -0,0 +1,2 @@ +package androidx.work; +public enum OutOfQuotaPolicy { RUN_AS_NON_EXPEDITED_WORK_REQUEST, DROP_WORK_REQUEST } diff --git a/android/tools/typecheck/stubs/java/androidx/work/PeriodicWorkRequest.java b/android/tools/typecheck/stubs/java/androidx/work/PeriodicWorkRequest.java new file mode 100644 index 0000000000..23eebe3a9e --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/PeriodicWorkRequest.java @@ -0,0 +1,9 @@ +package androidx.work; +import java.util.concurrent.TimeUnit; +public final class PeriodicWorkRequest { + public static final class Builder { + public Builder(Class workerClass, long repeatInterval, TimeUnit repeatIntervalTimeUnit) {} + public Builder setConstraints(Constraints c) { return this; } + public PeriodicWorkRequest build() { return null; } + } +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/WorkManager.java b/android/tools/typecheck/stubs/java/androidx/work/WorkManager.java new file mode 100644 index 0000000000..8c70b1ac3f --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/WorkManager.java @@ -0,0 +1,8 @@ +package androidx.work; +import android.content.Context; +public abstract class WorkManager { + public static WorkManager getInstance(Context context) { return null; } + public abstract Object cancelUniqueWork(String uniqueWorkName); + public abstract Object enqueueUniquePeriodicWork(String uniqueWorkName, ExistingPeriodicWorkPolicy policy, PeriodicWorkRequest request); + public final Object enqueueUniqueWork(String uniqueWorkName, ExistingWorkPolicy existingWorkPolicy, OneTimeWorkRequest work) { return null; } +} diff --git a/android/tools/typecheck/stubs/java/androidx/work/WorkerParameters.java b/android/tools/typecheck/stubs/java/androidx/work/WorkerParameters.java new file mode 100644 index 0000000000..6d3d5dbe2b --- /dev/null +++ b/android/tools/typecheck/stubs/java/androidx/work/WorkerParameters.java @@ -0,0 +1,2 @@ +package androidx.work; +public final class WorkerParameters {} diff --git a/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanContract.java b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanContract.java new file mode 100644 index 0000000000..3132f4eeec --- /dev/null +++ b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanContract.java @@ -0,0 +1,3 @@ +package com.journeyapps.barcodescanner; +import androidx.activity.result.contract.ActivityResultContract; +public class ScanContract extends ActivityResultContract {} diff --git a/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanIntentResult.java b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanIntentResult.java new file mode 100644 index 0000000000..1819791d78 --- /dev/null +++ b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanIntentResult.java @@ -0,0 +1,2 @@ +package com.journeyapps.barcodescanner; +public final class ScanIntentResult { public String getContents() { return null; } } diff --git a/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanOptions.java b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanOptions.java new file mode 100644 index 0000000000..391184e044 --- /dev/null +++ b/android/tools/typecheck/stubs/java/com/journeyapps/barcodescanner/ScanOptions.java @@ -0,0 +1,8 @@ +package com.journeyapps.barcodescanner; +public class ScanOptions { + public static final String QR_CODE = "QR_CODE"; + public final ScanOptions setDesiredBarcodeFormats(String... formats) { return this; } + public final ScanOptions setPrompt(String prompt) { return this; } + public ScanOptions setBeepEnabled(boolean enabled) { return this; } + public ScanOptions setOrientationLocked(boolean locked) { return this; } +} diff --git a/android/tools/typecheck/stubs/java/dev/nodeterm/android/R.java b/android/tools/typecheck/stubs/java/dev/nodeterm/android/R.java new file mode 100644 index 0000000000..8d0d0f0a98 --- /dev/null +++ b/android/tools/typecheck/stubs/java/dev/nodeterm/android/R.java @@ -0,0 +1,5 @@ +package dev.nodeterm.android; +public final class R { + public static final class string { public static final int app_name = 1, channel_attention = 2, channel_attention_desc = 3, channel_done = 4, channel_done_desc = 5, channel_sending = 7, channel_sending_desc = 8; } + public static final class drawable { public static final int ic_launcher_foreground = 6; } +} diff --git a/android/tools/typecheck/stubs/kotlin/androidx/activity/compose/Stubs.kt b/android/tools/typecheck/stubs/kotlin/androidx/activity/compose/Stubs.kt new file mode 100644 index 0000000000..b74af14c86 --- /dev/null +++ b/android/tools/typecheck/stubs/kotlin/androidx/activity/compose/Stubs.kt @@ -0,0 +1,10 @@ +package androidx.activity.compose +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +fun ComponentActivity.setContent(parent: CompositionContext? = null, content: @Composable () -> Unit) {} +@Composable fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) {} +class ManagedActivityResultLauncher : ActivityResultLauncher() +@Composable fun rememberLauncherForActivityResult(contract: ActivityResultContract, onResult: (O) -> Unit): ManagedActivityResultLauncher = ManagedActivityResultLauncher() diff --git a/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/platform/AndroidStubs.kt b/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/platform/AndroidStubs.kt new file mode 100644 index 0000000000..4885d75df7 --- /dev/null +++ b/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/platform/AndroidStubs.kt @@ -0,0 +1,4 @@ +package androidx.compose.ui.platform +import android.content.Context +import androidx.compose.runtime.staticCompositionLocalOf +val LocalContext = staticCompositionLocalOf { error("no context") } diff --git a/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/viewinterop/AndroidView.kt b/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/viewinterop/AndroidView.kt new file mode 100644 index 0000000000..e55b19e61d --- /dev/null +++ b/android/tools/typecheck/stubs/kotlin/androidx/compose/ui/viewinterop/AndroidView.kt @@ -0,0 +1,6 @@ +package androidx.compose.ui.viewinterop +import android.content.Context +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +@Composable fun AndroidView(factory: (Context) -> T, modifier: Modifier = Modifier, update: (T) -> Unit = {}) {} diff --git a/android/tools/typecheck/stubs/kotlin/androidx/lifecycle/compose/Stubs.kt b/android/tools/typecheck/stubs/kotlin/androidx/lifecycle/compose/Stubs.kt new file mode 100644 index 0000000000..acef0c9388 --- /dev/null +++ b/android/tools/typecheck/stubs/kotlin/androidx/lifecycle/compose/Stubs.kt @@ -0,0 +1,9 @@ +package androidx.lifecycle.compose +import androidx.compose.runtime.Composable +// lifecycle-runtime-compose's start/stop effect, reduced to the shape the app calls (the real +// onStopOrDispose block has a LifecycleOwner receiver; a plain lambda compiles against both). +class LifecycleStopOrDisposeEffectResult +open class LifecycleStartStopEffectScope { + fun onStopOrDispose(onStopOrDisposeEffect: () -> Unit): LifecycleStopOrDisposeEffectResult = LifecycleStopOrDisposeEffectResult() +} +@Composable fun LifecycleStartEffect(key1: Any?, effects: LifecycleStartStopEffectScope.() -> LifecycleStopOrDisposeEffectResult) {} diff --git a/android/tools/typecheck/stubs/kotlin/androidx/work/Stubs.kt b/android/tools/typecheck/stubs/kotlin/androidx/work/Stubs.kt new file mode 100644 index 0000000000..3fce8f45d1 --- /dev/null +++ b/android/tools/typecheck/stubs/kotlin/androidx/work/Stubs.kt @@ -0,0 +1,11 @@ +package androidx.work +import android.content.Context +import java.util.concurrent.TimeUnit +abstract class CoroutineWorker(appContext: Context, params: WorkerParameters) : ListenableWorker(appContext, params) { + abstract suspend fun doWork(): Result + open suspend fun getForegroundInfo(): ForegroundInfo = throw IllegalStateException("Not implemented") +} +inline fun PeriodicWorkRequestBuilder(repeatInterval: Long, repeatIntervalTimeUnit: TimeUnit): PeriodicWorkRequest.Builder = + PeriodicWorkRequest.Builder(W::class.java, repeatInterval, repeatIntervalTimeUnit) +inline fun OneTimeWorkRequestBuilder(): OneTimeWorkRequest.Builder = + OneTimeWorkRequest.Builder(W::class.java) diff --git a/docs/android-audit-2026-09.md b/docs/android-audit-2026-09.md new file mode 100644 index 0000000000..ff1b4966b7 --- /dev/null +++ b/docs/android-audit-2026-09.md @@ -0,0 +1,5670 @@ +# Android companion: audit findings (2026-09-25) + +Generated from the adversarially verified audit run `wf_cfa3d2db-264` (170 agents). Six auditors covered build, protocol, runtime, security, iOS parity and CI/docs; a completeness critic ran afterwards. Refuters checked every finding against the source (two refuters for high/critical findings), then an impact judge re-rated its severity. **All 77 findings survived and none were refuted.** Treat that with some caution: a fix session should re-read the cited code before changing it. Line numbers refer to commit `2f58918`. + +Severity is the impact judge's rating, not the auditor's claim. **BLOCK** means the judge said it blocks a first sideloaded release. Effort is the judge's estimate. + +Prioritised plan and handover: [`android-handover.md`](android-handover.md). + +**Status of fixes.** A fixed finding is marked `✅ fixed in ` in the index, and a finding +deliberately not built is marked `📝` with the reason; its section below +keeps the original audit text (line numbers still refer to `2f58918`). Continuation findings +`A78`–`A80` were confirmed against cached branch tip `6afd8f53` on 2026-10-02; their local +fix commits were initially checked only within a restricted sandbox. Later local AGP work found +`A83`, and the first full local protocol run found the test-isolation fault `A84`; current build, +full-suite and phone verification are tracked in the handover. The private APKs use authorized +local builds; each requested push requires green Android workflow verification. Where the fix departs from +the audit's proposal, the handover's progress log says how and why. + +**Upstream PR conflict integration (2026-10-08).** The Android contribution now incorporates +upstream `main` at `adde5e85` (v0.4.2), retaining the phone's typed host verbs, managed terminal +ownership, SSH-visible files and marker-framed v2 answers alongside upstream chat, role-scoped +pairing and session backends. Android also recognizes upstream Antigravity and matches its +`agy` launch/resume grammar without inventing approval flags. The merged managed hook uses revision 6: older revision-5 hooks +are not admitted for upstream unmarked structured answers, while legacy phone answers keep +their separate contract. The current merge has its own offline, desktop and CI checks; it does +not change the source binding of the prepared beta23 APK or the last verified installed +beta22/code23. Merged-source device acceptance remains pending, and the original +**10 Pass / 22 Partial / 32 Pending** ledger is unchanged. **iOS implications for @eneskirca:** +the upstream chat/role changes and new structured-answer revision gate are shared host behavior; +the existing Android/iOS phone verbs and v2 framing remain compatible. + +**Pending Send after remount (2026-10-08, A137 — published; beta23 prepared).** +Pending Send state and guidance belong to the retained entry, with exact button/IME admission and +uncertainty preserved until dismissal. Source `b9e4cbc7` passes focused66/9, thirteen intended mutants, +fresh1069/116/offline app and CI37740459063 all-five/ten-step. Retained-signer beta23/code24 is +prepared and reviewed with 46 available SDK36/R8/metadata checks; actual SDK37 verification is absent. +A read-only pre-install snapshot refused before any update dispatch. Installation and the controlled +pending-Send phone case remain unverified; beta22/code23/source `2ba0b142` is last verified installed. +The original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. See [the scoped finding](android.md#pending-send-outcome-after-remount-2026-10-08-a137). + +**Beta22 remount follow-up (2026-10-08 — bounded physical pass).** On the Pixel 7a, +retained-signer beta22/code23 (`fa894d4b`, source `2ba0b142`) keeps the exact unsent draft and +checked Ctrl through one normal locally rejected Pair screen and Back. Both original Bash panes, +server/socket, captures and desktop clients stay unchanged; the phone client reattaches under its +same SSH parent/session/TTY. No Send or producer runs. Source `2ba0b142` passes **1054/114**, offline +app compilation and CI37732792493 all-five/ten-step. In-place update preserves measured install +time and full runtime-permission blocks. Owned profile/auth/reverse/fixture cleanup is verified; +raw-input Ctrl timing, selection/IME and pending-Send checks remain unverified. The original +**10 Pass / 22 Partial / 32 Pending** ledger and installed artifact source stay unchanged. + +**Retained terminal editor (2026-10-08, A136 — source fixed; bounded fixed-phone pass).** +The Pixel 7a/beta21 loses its unsent draft and armed Ctrl after another screen covers the terminal +and Back returns. The fix keeps the full editor and Ctrl state with that live navigation entry. +Nine store and three wiring regressions pass; seven policy mutants and one wiring mutant are +caught. Beta22/code23 is published and installed; one retained-entry remount now passes. The +original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged; see +[the scoped evidence](android.md#retained-terminal-editor-2026-10-08-a136). + +**Captured Ctrl consumption (2026-10-08, A135 — source fixed).** Delayed raw-keyboard +consumption now checks the captured Ctrl revision as well as its viewer. Five policy and three +wiring regressions pass; three policy mutants and one wiring mutant are caught. Source publication +and beta22 installation pass; a physical scheduling reproduction remains pending. See +[the scoped evidence](android.md#captured-ctrl-consumption-2026-10-08-a135). + +**Lost Send reply/newer draft (2026-10-08 — bounded physical pass).** A fresh Pixel 7a/beta21 +direct-SSH case loses the original reader's successful 22-byte Enter receipt before it can return +to userspace. The exact newer 146-character draft is observed with Send disabled before loss; +it remains afterward while the app reconnects. One original-TTY witness/ACK stays unique at a +late observation more than 120 seconds later, and automatic recovery attaches a new viewer to +the original Bash/server/socket/producer with the complete sibling guard unchanged. Observed +checkout `5a3013dd`, Desktop build `0d8594a6` and installed APK/source `ef4caec2` are distinct. +Owned cleanup and original 15000-ms timeout restoration pass. Android's internal result is not +observed; network blackhole, whole-host loss, remount/Ctrl during pending Send, native TLS/history +and the full device matrix remain unverified. The original **10/22/32** ledger stays unchanged. +See [the measured receipt loss](android.md#lost-send-reply-and-newer-draft-2026-10-08). +A new real SSH/tmux second-Enter acknowledgement regression passes healthy/restored controls; +its false-success mutant is caught. Production behavior and the installed APK are unchanged. + +**Held Send reply/newer draft (2026-10-08 — bounded physical pass).** A fresh Pixel 7a/direct-SSH +case observes the exact newer 146-character draft with Send disabled before a measured +9.000280-second post-execution receipt hold ends; after release the same draft remains with Send +enabled. One original-TTY witness/ACK stays unique 168.735781 seconds later, with the original +Bash/server/producer/socket/guard retained. Observed checkout `50e7a876`, Desktop build `0d8594a6` +and installed beta21/code22 APK/source `ef4caec2` are distinct; no product or APK change occurs. +Owned phone/host cleanup completes, with original timeout 15000 ms independently confirmed after +an uncertain restore, without replay. At that checkpoint, lost reply, network blackhole, +remount/Ctrl during pending Send and native TLS/history remained unverified; the original +**10/22/32** ledger is unchanged. +See [the measured receipt hold](android.md#held-send-reply-and-newer-draft-2026-10-08). + +**Earlier nine-second Send/newer-draft follow-up (2026-10-08 — bounded partial).** A fresh `d361ce8f` +fixture uses unchanged Desktop/build `0d8594a6` and stock beta21/APK source `ef4caec2`. +One Send and a draft suffix return before the same SSH peer resumes, but the final newer-draft/ +disabled-Send XML observation misses the pause deadline; the original wrapper remains failed. +Later XML retains the exact newer draft and saved pixels show its suffix; Send is enabled and one +original-TTY witness/real ACK stays unique 125.310 seconds later. At that earlier checkpoint, the +strict timed race and lost-reply cases were unverified; the failed wrapper remains preserved. +See [the bounded follow-up](android.md#nine-second-send-and-newer-draft-follow-up-2026-10-08). + +**Silent SSH peer recovery (2026-10-08, Pixel 7a / beta21 — bounded pass).** Fresh owned checkout +`04da9b9e` uses unchanged Desktop/build `0d8594a6` and installed APK/source `ef4caec2`. Only the +admitted old SSH peer is stopped for 45.000 seconds on shared loopback; automatic recovery creates +a new viewer while it is paused, preserving the original shell, producer, tmux server/socket and +guard. The unsent draft and its saved rendered text remain; one later explicit Send executes once +and receives a real ACK. At that checkpoint, whole-host/network loss, cursor position and +lost-reply races remained unverified. Owned cleanup passes; the original **10/22/32** ledger stays +unchanged. See +[the bounded receipt](android.md#silent-ssh-peer-recovery-and-single-send-2026-10-08). + +**Native Desktop continuity (2026-10-08 — scoped verification).** A fresh private Desktop/build +`0d8594a6`, observed with the docs-only `98907171` checkout, passes two original session-host +factories, retained capture of all 200 unique numbered markers with Unicode, one normal renderer +reload and input to the same original shell before/after it. The complete sibling capture and +original daemon/Bash/producer/socket generations stay unchanged; owned retirement is verified. +Android native-history/Live-overlay acceptance, silent network loss and native-route held/lost replies remain open. +Installed beta21 and the original **10 Pass / 22 Partial / 32 Pending** ledger are unchanged. +See [the native consumer receipt](android.md#native-desktop-capture-and-renderer-reattach-2026-10-08). + +**Login-shell deadline (2026-10-08, A134 — published; original create verified).** +Signed, pushed `3f382367`/`0d8594a6` pass forced **1033 methods / 110 suites**, offline app compilation, +full TypeScript and **161 tests in nine affected Vitest files**, with five known Windows-only skips. +[Run `37702852854`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37702852854), attempt 1, passes all five jobs and ten required steps. +A fresh source-built Desktop observes one original Linux session-host create fulfill and display +its Bash prompt, with zero PTY input. V13's child cause and whether fallback fired remain unknown; +native history and its phone overlay remain pending. Installed beta21/source `ef4caec2` and the +original **10 Pass / 22 Partial / 32 Pending** ledger are unchanged. +See [the scoped source checkpoint](android.md#login-shell-probe-completion-deadline-2026-10-08-a134). + +**Direct SSH continuity (2026-10-08, Pixel 7a / beta21).** Desktop `0d8594a6` with the unchanged +stock APK/source `ef4caec2` passes controlled SSH-handler recovery, two active Home/resume cycles, +draft retention and one explicit Send to the original tmux pane. The producer/server/socket and +sibling guard remain unchanged. Phone cleanup is verified; the outer runner stop-accounting +negative is preserved. Native-history/relay/notification and wider lifecycle cases stay open. +See [the bounded continuity receipt](android.md#direct-ssh-reconnect-and-active-background-continuity-2026-10-08). + +**Beta 21 published and installed (2026-10-07 — bounded closed SSH display verified).** +Signed, pushed `ef4caec2` passes forced **1033 methods / 110 suites**, zero failures/errors/skips, +offline app compilation and full TypeScript. [Android run `37678829862`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37678829862), +attempt 1, passes all five jobs and ten required steps. Retained-signer **beta 21/code 22** is +installed on Pixel 7a, APK SHA-256 `91638d05e6192794675dee6a045f53f19cdea707187ebb3482b8ae3f10320120`. +All **46 SDK 36 artifact checks** pass; actual SDK 37 tools are unavailable. The 5.083-second +beta-20→21 update preserves first-install time and the complete notification grant/flags; +the same owned SSH profile and session rows remain visible after the update. +A fresh owned direct-SSH producer completes all 1200 numbered rows and its confirmed 24-byte +completion write in **48.23 seconds**. Actual producer progress crosses Home/resume; later phone +screens show numbered/Unicode rows, the completion marker and older tmux history after a swipe. +The crossing uses receipt/filesystem order, not exact phone-monotonic latency. The original A132 +negative remains separate, with attribution open. A133's same-page exit-0 display retains eight +Unicode rows, wraps and the final tail; Copy-sheet filtering/selection, exact OSC8/plain URL offers +and font redraw pass. A fresh same-page follow-up copies the exact final marker and one Unicode +row through the normal clipboard into the ended composer; OSC8/plain link offers match after +smaller and restored fonts. At that copy/link checkpoint, browser navigation and Share were untested; wider lifecycle/TUI checks remain unverified. A129/A130 native-history acceptance and wider lifecycle/notification checks remain separate. The original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. +The preceding docs-only `9b8bba2b` checkpoint passes forced 1033/110/app/TypeScript and +[Android run `37690764287`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37690764287), +all five jobs and ten required steps. The build and installed APK remain bound to `ef4caec2`; +subsequent documentation publication requires fresh exact-revision gates and CI receipts. +The user authorized ongoing tests without a screen lock: ordinary wake and app launch are allowed, +with no PIN input or lock/security changes. The owned QA profile/public-key line/reverse are +removed, actual timeout readback confirms the original 15000 ms, and the exact disposable host +retires with listeners closed and private authentication removed. Beta21 remains installed. The regular Desktop is not deployed +and no PR is opened. + +**Fresh native diagnostics (2026-10-08; source unchanged).** The `9b8bba2b` normal-Fedora +40-ms run records all 1200 rows and its standalone marker; original A132 attribution stays open. +Linux session-host V8/V9 pilots stay blank before input; V9's 30-second gate/all-thread observations +still do not prove original creation or attachment. A129/A130 acceptance remains pending. See +[the scoped evidence](android.md#fresh-fedora-output-and-native-desktop-readiness-2026-10-08). + +**Browser/Share pilot (2026-10-08; bounded live SSH).** Normal Copy Links Open reaches Vanadium's +exact nonce address and rendered content; Share previews only the selected marker and Back returns +to the owned terminal. Browser Back remains unverified; ordinary resume succeeds. Original +producer/guard continuity and exact owned cleanup pass; Open-focus and timeout uncertainties +are reconciled without replay. +No recipient, closed-page browser-lifecycle, wider matrix or ledger credit is claimed. + +**Earlier beta 20 publication and installation (2026-10-07, A131 — physical checks partial).** +Signed `11fbff08` passes forced **1029 methods / 108 suites**, zero failures/errors/skips, +offline app compilation and full TypeScript; [Android run `37659190980`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37659190980) +passes all five jobs and ten required steps. Retained-signer **beta 20/code 21** is installed on +Pixel 7a; the 4.875-second beta-19→20 update preserves first-install time and notification grant/flags. +A resumed direct-SSH screenshot contains later numbered rows than the earlier capture; active +production during backgrounding is unproven, so no background-delivery pass is claimed. The guard +shows only its own prompt. All 1200 numbered rows remain in native capture, but its completion marker is +absent there too ([A132](android-audit-2026-09.md#a132), attribution pending). After a separate +phone Send/exit, the terminal shows `[exited]` and an exit-0 overlay without the previously displayed +history ([A133](android-audit-2026-09.md#a133), cause pending); final-marker emission was not captured. +A131's focused **71/8** and **10 behavioral/four app source-pin mutants** remain source evidence, +not full lifecycle acceptance. Already-dispatched WebView JavaScript, A129/A130 native history, +wider lifecycle/notification cases and the original **10 Pass / 22 Partial / 32 Pending** ledger +remain outside these observations. See [the bounded receipts](android.md#beta-20-publication-and-bounded-output-checks-2026-10-07). + +**Closed SSH/tmux display follow-up (A133 — published source repair; bounded physical checks pass).** +Native raw exit traces confirm `CSI 2 J` erases the visible alternate pane before `CSI ?1049l`. +This change retains that pane only at an admitted SSH/tmux EOF, as an inert same-page display with +Copy/links; live parsing, scrolling and transport capabilities stay unchanged. **27 shipped-xterm +component cases pass in the permanent behavioral JUnit regression**, alongside **three passing +app source-pin methods** and the existing swipe suite. The first full run caught a same-view delayed +Enter regression; layout changes now preserve it while viewer retirement still cancels it. Its +27-case mutation/control/restored checks pass. Earlier **11 semantic mutants** plus one geometry +mutant and three separate app source-pin mutants remain separately recorded below. Final +1033/110/app/TypeScript gates and all-five/ten-step CI pass at `ef4caec2`; beta 21/code 22 is +installed. Bounded same-page closed-screen, Copy-sheet, exact final-marker/Unicode clipboard +paste and post-font OSC8/plain link-offer checks pass. The later normal browser/Share pilot is +bounded as above; wider lifecycle/TUI checks remain unverified. The earlier beta 20 +came from `11fbff08`, with `656e5d8a` as its later docs-only head. A132's clean native +short/long-prompt controls pass, but the +original Fedora 40-ms/phone completion-marker case remains open. The original 64-row ledger stays +**10 Pass / 22 Partial / 32 Pending**. The equivalent iOS EOF/display behavior needs adoption review +by @eneskirca; no wire change is introduced. + +**Earlier beta 19 checkpoint (2026-10-07 — bounded direct-SSH checks pass).** +Signed, pushed `5bda2c32` includes the A130 held Live-touch fix. Forced fresh offline protocol +checks pass **1015 methods / 106 suites**, zero failures/errors/skips; offline app compilation +and full TypeScript pass. [Android run `37608652625`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37608652625) +passes all five jobs and ten required steps. Retained-signer **beta 19/code 20** is installed +on the intended Pixel 7a, SHA-256 `e7271ebe1c1d65d8f6a09f7c59ab4b0042081c152c421568caabdcf6965bad42`. +The 6.18-second beta-17→19 update preserves first-install time and the notification grant/flags; +actual SDK 36 artifact checks pass and SDK 37 tools are unavailable. Public SSH identity continuity +and a fresh explicit-profile first pin pass through an owned ADB reverse tunnel. Emacs/vi copy-mode +Send, brief background/resume draft retention, neutral viewer-close/manual Reattach and controlled +SSH-handler recovery pass on the disposable Linux host; that earlier owned cleanup passes. +Saved-profile/pin reuse and manual changed-key refusal now pass too; A129 native/session-host +history, A130 held Live touch and the wider lifecycle/provider matrix remain pending. The original +**10 Pass / 22 Partial / 32 Pending** ledger is unchanged. + +**A105/A106 Pixel 7a checkpoint (2026-10-07, Desktop `e06b5547` / beta 19).** Normal Always +allow applies one original `localSettings` rule; real sibling Claude makes two Bash calls without +another hold. One original two-question request submits Cobalt (single) and Lint/Docs (multi), +with real CLI tool/Stop/final-output confirmation. Saved-profile/pin reuse and changed-key refusal +pass, and both owned fixtures and the QA profile/key/reverse retire cleanly. Rule restart-persistence, +production-pane-child launch and the wider matrix remain pending. + +The fresh API-30 emulator boots initially but remains offline +after its one provisioning reboot; its bounded reconnect/screenshot/debugger diagnostics and +owned cleanup add no app/CA/APK acceptance. See [the scoped evidence](android.md#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07). + +**Earlier native history publication (2026-10-07, A129 — physical acceptance pending).** +Signed backend `0818bbed` and host/Android/producer/CI `2d693263` are included in the signed, +pushed integration `1add0408`. They implement +`pty.attach`'s `scrollV1` capability and `pty.scrollV1` for bounded, inert native/session-host +history. A valid viewer token continues paging its immutable snapshot despite live application +mouse-mode changes; Live, actual user input, resize or an explicit new intent clears that view. +A new intent checks the actual backend mode before any wheel input. Source publication and +mandatory verification pass; actual app/WebView, physical ConPTY and phone acceptance remain +unverified. + +Keep the proof layers separate: **34 emulator tests / 3 files + 20 assertion-caught mutants**; +**343 backend tests / 37 files + 21 assertion-caught mutants**, with four existing Windows-only +skips on Linux; **13 helper/RPC control/restored tests + 18 assertion-caught mutants**; and one +fixture-cleanup test with one assertion-caught mutant. Focused Android control/restored runs pass +**99 methods / 11 suites** and the offline app check. Its **14 assertion-caught variants** are +**13 behavioral mutations + one posted-Runnable source pin**. Separate CI-reader control/restored +checks pass **11 methods** and catch **eight configuration-input deletions**; these are not +additional production mutations. No aggregate combines these layers or earlier attempts. +The root helper/RPC receipt is `a129-history-view-proof-v4-67xv3v57/mutation-result.json`, SHA-256 +`fe62c4f299afa9a7254ddb8aaf8e052386e71f7742917565c90363a8fb025583`. +At exact `1add0408`, forced fresh offline protocol checks pass **1012 methods / 105 suites** +with zero failures/errors/skips; offline app compilation, **568 affected Vitest tests / 52 files** +with exactly four existing Windows process-tree skips, and full TypeScript pass. +[Android run `37602046133`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37602046133), +attempt 1, passes all five jobs and ten required steps. Private +`a129-publication-biru7yjl/gates.json`, `ci-result.json` and `final-report.json` bind this source. +The retained-signer **beta 18/code 19** APK is ready and **not installed on the intended phone**, SHA-256 +`19d55ee177335064b1edb4874b6d27216862d16a7d30ebe476875c566cd263e0`. Actual SDK 36 artifact +checks pass; SDK 37 artifact verification is unavailable. These are source/component/recorder +and browser-harness boundaries, not full Desktop/WebView or phone acceptance. The regular +Desktop is not deployed and no PR is opened. +The separate published A128 checkpoint `944223ef` has **984 protocol methods / 102 suites**, +**360 affected Vitest tests / 26 files** and all five jobs/ten required steps green in +[Android run `37594743169`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37594743169). +Its private `a128-publication-1vq7hcgc/gates.json` and `ci-result.json` retain that exact source +binding. At that earlier checkpoint, installed beta 17/code 18 remained from `a79375c3`; no new physical acceptance or change +to the original **10 Pass / 22 Partial / 32 Pending** ledger is claimed. + +**Held Live-button published follow-up (2026-10-07, A130 — app acceptance pending).** +Signed `9da36320`, included in published `5bda2c32`, stops old momentum and the native pending-scroll +queue on touch-down, preserving history and its display epoch until click. Three new LiveTouch +JVM methods pass within **48 methods / 7 suites** in control and restored runs, with zero +failures/errors/skips. **Three semantic mutants** (old handler, premature history close, parent +gesture) fail assertions. Exact-head final gates/CI pass and beta 19/code 20 is installed; +actual WebView/physical acceptance remains pending. The `1add0408` failure remains historical. + +**Composed Send backend follow-up (2026-10-07, A128).** Current source also supports the +attached direct native Windows PTY and a session host that negotiates `composed-input-v1`. +The host owns one-use tickets scoped to its live session generation and the original subscriber +socket; paste and Enter remain separate writes at least 150 ms apart. A lost receipt after input +may have been sent stays uncertain and never replays. Older live hosts refuse without being +restarted. The public Android `pty.submitComposed` request/result is unchanged; its actual +backend producers are tested through the relay and Kotlin client. Windows ConPTY and phone +acceptance of these additional backends remain unverified. At that earlier checkpoint, installed beta 17 still came +from `a79375c3`; that host-side follow-up did not represent a new installed APK. + +**Beta 17 Pixel 7a follow-up (2026-10-06, A127/A128).** Retained-signer beta 17/code 18 +from `a79375c3` is installed on the intended Pixel 7a (Android 17/API 37, +Vanadium `154.0.8037.126.0`) against a fresh disposable Desktop from the same source. +The 5.48-second same-signer update preserves the first-install time and saved temporary SSH +profile; Sessions reopens without editing its pin or readmitting the phone key. One emacs +copy-mode Send submits exactly once to the original producer, exits copy mode and leaves the +sibling guard unchanged. The A127 viewer-only exit now shows a neutral connection-close notice, +disables Send and retains the draft; manual Reattach preserves the same producer and complete +same-grid history, and the retained draft submits once. These are bounded repaired-device cases; +vi copy-mode recovery/Send also preserves the pane and full same-grid history, with the draft +retained and Send disabled during the outage, then one native submission after recovery. One +Ctrl+C action reaches the owned raw TTY consumer as exactly `03`, with no extra byte in a bounded +0.8-second window. Actual phone-managed shell creation and exact owned SSH End pass too: +the producer/pane/viewers end while the sibling guard and expected canvas node remain. +Only the temporary profile/public-key admission are retired; the nonce-validated host stop +removes generated private authentication and closes both owned listeners. Nine bounded cases +and 303 raw-artifact hashes are recorded in the frozen report; ordinary native Quit is unverified. + +At exact merged `a79375c3`, full forced offline protocol checks pass **982 methods / 102 suites**, +zero failures/errors/skips; offline app compilation, **187 affected Vitest tests / 11 files** +and full TypeScript pass. [Android run `37523703627`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37523703627) +passes all five jobs and ten required critical steps. The APK and installed public-code pull +both hash to `c51433d4…`; actual retained-signer verification uses SDK 36, with SDK 37 tools +unavailable. A127 is source-fixed in `0d50075a`; A128 in `7e7d2c04`, with beta CI registration +`5484ab9f` and **30 assertion-caught source/configuration mutations**. Older beta-16 failures +remain historical. This does not complete the 64-item checklist or the original Pixel 10 Pro +ledger, which stays **10 Pass / 22 Partial / 32 Pending**. See [the bounded beta-17 receipt](android.md#pixel-7a-beta-17-upgrade-and-composed-send-2026-10-06). + +**Desktop SSH reconnect checkpoint (2026-10-06, A125/A126).** A125 is source-fixed in `415dae9b`; A126 is committed in `5b7286b3`. +Eight actual Linux Desktop **Canvas/Modal × soft/hard wraps × inner none/SGR mouse** cases +pass at `5b7286b3`. Each loses one birth-pinned product ControlMaster, creates a new master/ +selected local viewer and retains its remote pane/server/producer/cwd and complete same-grid history before, +during, after and at final observation. Both trusted actual textarea `g` inputs are acknowledged +on the old/new PTY: **16 ACKs**, **zero unexpected bytes**, one producer paint per case and +**16 complete URL activations**. Native hover/leave and exact owned cleanup pass. Modal cases +use `80x24`; Canvas cases use `49x48`, with each history comparison bound to its own grid/case. +**294 unique affected tests / 17 files** and full incremental TypeScript pass. **15 isolated +mutants (six A125 + nine A126)** are caught by assertions; each fix's control/restored runs pass. +Forced offline checks at `5b7286b3` execute **950 tests / 98 suites**, zero failures/errors/skips, +and both protocol/app Kotlin compile tasks. These are disposable QA hosts; no new APK or regular +Desktop installation is included. Publication requires fresh offline gates and all five Android +CI jobs for the exact published HEAD; its private bundle and final report carry that run's result. +A separate Linux two-host soft/none case passes at `59e4c93e`: one quiet park/adopt cycle, +inactive B global-card warm reconnect while A stays active and unchanged, **3 native ACKs / 2 +complete URL activations**, retained same-grid history and exact owned cleanup. See [the bounded two-host follow-up](android.md#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06). +The earlier negative/unclassified controls remain historical. Phone release/backend checks still +need their own acceptance; wider parking, Server/cross-window, outage/platform and GPU coverage +remain separate. +Unsupported private shapes preserve input with a warning but may duplicate replies. Android +wire/file/iOS adoption is unchanged. The original Pixel 10 Pro ledger stays paused at +**10 Pass / 22 Partial / 32 Pending**. + +**Fresh-phone checkpoint (2026-10-05, A124).** A separate Pixel 7a has a fresh, +hash-verified, non-debuggable beta 16/code 17 installation (Android 16 / API 36, +Vanadium WebView `145.0.7632.120.0`). Manual explicit-profile, high-port SSH and the existing +terminal's input/cwd/Unicode history and bounded held-drag/coast/new-touch-stop checks pass. +The original **New session → Start** attempt kept default Claude selected and failed shared +shell validation on host `15dd1341`. A124 (`651f46da`) fixes absolute executable resolution. +**The repaired single managed-shell case passes physically on Desktop `40731381`:** actual shell +radio selection, canvas registration, input exactly once, viewer reopen and saved SSH reconnect +after an Android process restart all preserve the same pane and the other original terminal. +Offscreen retained-history Find, selected-line clipboard paste and Copy-sheet URL clipboard +roundtrip also pass. Both disposable QA host profiles were removed through the app; beta 16 and +the phone identity remain. The repaired host stopped cleanly with generated authentication removed +and listeners closed. The original Pixel 10 Pro beta-10 checklist stays paused at +**10 Pass / 22 Partial / 32 Pending**. Fresh-install/single-case results do not verify its update, +live agents, managed End, power loss, QR/relay/cellular routes or the full device matrix. +Dated receipts below remain historical. + +At checkpoint `40731381`, offline protocol tests pass **949 methods / 98 suites**, with zero +failures, errors or skips; **38 affected Vitest tests** pass with five platform skips, full +TypeScript passes, and all three isolated guard mutants fail their expected assertions. +The frozen full Desktop build passes in **29.3 seconds**. The first push has all five Android jobs +green in [run `37375870878`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37375870878), +verified against exact head `40731381c646ccd3425af597b116b5371daf0a63`. Later pushes need their own gates and CI. + +## Index + +**Native custom-wheel checkpoint (2026-10-06, A86).** Both tmux copy key modes pass eight +settled positions over real SSH and the input actor; three native distance mutants are caught. +The [native receipt](android.md#native-custom-wheel-checks-2026-10-06) distinguishes this coverage +from the later bounded Pixel 7a custom-binding/lifecycle cases; the wider gesture/reversal, +lifecycle and FPS matrix remains open. + +| ID | Sev | Block | Effort | Area | Title | +|---|---|---|---|---|---| +| [A01](#a01) | high | BLOCK | small | build/bug | ✅ fixed in `af1f820` · Direct-SSH terminal writes and resizes run on the Android main thread, which breaks the SSH connection as soon as the keyboard opens | +| [A02](#a02) | high | BLOCK | small | protocol/bug | ✅ fixed in `e7c22eb` · SSH browse looks for the desktop's userData under 'nodeterm', but the desktop writes it under 'node-terminal', so every direct-SSH listing comes back empty | +| [A03](#a03) | high | BLOCK | small | protocol/bug | ✅ fixed in `cd69a1e` · The direct-SSH tmux client starts without a UTF-8 locale, so tmux replaces every non-ASCII character with '_' | +| [A04](#a04) | high | BLOCK | small | runtime/bug | ✅ fixed in `af1f820` · SSH terminal writes and resizes run on the main thread, and the NetworkOnMainThreadException that runCatching swallows leaves sshj's cipher state out of sync, which drops the whole SSH connection | +| [A05](#a05) | high | BLOCK | small | runtime/risk | ✅ fixed in `3d36d60` · The background InboxWorker can open a never-approved relay handshake and raise the desktop's SAS approval dialog while the phone shows no code | +| [A06](#a06) | high | BLOCK | small | security/bug | ✅ fixed in `16706f4` · approvals.answer reports success for a hold that already timed out, and clears NEEDS YOU on every surface | +| [A07](#a07) | high | BLOCK | medium | critic/bug | ✅ fixed in `0fa0646`; follow-ups `c199349`, `a988a30` (revoke closes a live relay session), `f66fddf`, `07b7276` (a late-adopting paired phone is approved on its first handshake) · Remote access fails the first time you are away from the computer: Auto never does the first relay handshake while the phone is at the desk, and pairing does not pin the phone's relay key | +| [A08](#a08) | high | BLOCK | small | critic/bug | ✅ fixed in `1cdd2f0` · A cold attach over direct SSH creates the desktop's tmux session with no hook environment, so an agent resumed there never reports status, and the desktop never repairs it | +| [A09](#a09) | medium | BLOCK | small | protocol/bug | ✅ fixed in `726271a,1cdd2f0` · Nodes of SSH projects open against the desktop's LOCAL tmux, creating an empty phantom session and offering to resume the conversation on the wrong machine | +| [A10](#a10) | medium | BLOCK | small | ci-docs/risk | ✅ fixed in `fcda932` · Debug APKs from CI change signature from run to run; README offers them as the install route, and updating means uninstalling, which wipes pairings | +| [A11](#a11) | medium | | small | build/bug | ✅ fixed in `de1eded` · Tapping an Inbox notification while the app is in the background does not open that computer | +| [A12](#a12) | medium | | medium | protocol/bug | ✅ fixed in `ab1335c`; follow-up `8430243` (SSH-project nodes typed over their ControlMaster) · Relay sendKeys (question answers, legacy approvals) writes into a pty that does not exist yet and then kills it immediately, so the keystroke can be lost while the UI reports success | +| [A13](#a13) | medium | | medium | protocol/bug | ✅ fixed in `68d0925` (phone), `b718a04` / `ed2965be` (desktop, both routes); seven real tmux methods, eight Linux native checks and 24 distinct assertion mutants, physical pending · Registering a phone-started node while the phone is attached lets the desktop's own client attach with `-D`, which detaches the phone; Android then reports the session as ended | +| [A14](#a14) | medium | | small | protocol/bug | ✅ fixed in `c58ad65` · New sessions in cwd-less (inline) projects are never registered: the desktop refuses them, the refusal is ignored, and the session is orphaned | +| [A15](#a15) | medium | | small | protocol/bug | ✅ fixed in `c58ad65` · The cold-attach resume offer drops the node's managed Claude account (and on the relay, its cwd), so the resume fails with 'No conversation found' | +| [A16](#a16) | medium | | small | protocol/gap | ✅ fixed in `c58ad65` · The phone's launch ignores the project's own permission mode (and default account), so a project the user set to a stricter mode starts in the global mode | +| [A17](#a17) | medium | | small | protocol/risk | ✅ fixed in `3d36d60` · The background inbox worker dials the relay for unapproved phones, putting the desktop's SAS approval dialog up every 15 minutes with no code on the phone to compare it against | +| [A18](#a18) | medium | | small | runtime/bug | ✅ fixed in `10de4b9` · No lifecycle handling: a backgrounded app keeps the 8 s poll and its relay terminal stream alive indefinitely | +| [A19](#a19) | medium | | small | runtime/bug | ✅ fixed in `de1eded` · Tapping an inbox notification while the activity is alive ignores the target computer | +| [A20](#a20) | medium | | small | runtime/bug | ✅ fixed in `d199f03` · Cancelling an in-flight connect leaks the SSH connection and records the cancellation as a connection failure | +| [A21](#a21) | medium | | small | runtime/bug | ✅ fixed in `daf15d3` · A denied POST_NOTIFICATIONS is never re-requested, and the Settings switch still reads On | +| [A22](#a22) | medium | | small | runtime/bug | ✅ fixed in `ade7428` · The Navigator back stack is not saved across activity recreation, and the original launch intent is re-applied | +| [A23](#a23) | medium | | small | security/risk | ✅ fixed in `3d36d60` · Background inbox worker opens unapproved relay connections, raising desktop SAS approval dialogs the phone never shows | +| [A24](#a24) | medium | | small | security/bug | ✅ fixed in `8e304db` · SecureStore treats ANY decrypt error as 'absent', so getOrCreate32 permanently overwrites the phone's identity | +| [A25](#a25) | medium | | medium | parity/gap | 🟡 in-app part fixed in `4ffb8b7`, `0d310cc` (notification actions, the tap opens the session); FCM push is still a backend gap · No real push notifications: 15-minute background polling, no notification actions, no Live-Activity equivalent, and the desktop's phone-push switches are ignored | +| [A26](#a26) | medium | | medium | parity/gap | ✅ fixed in `9cdc4cf`, `1eeb5b8` · New session and board edits were unavailable on the LAN (direct-SSH) connection that Auto picks first; iOS does both over SSH. A108 now serves owned Board writes and Desktop delivery-only nudges over SSH; A90 adds separate plain SSH shells. Canvas registration remains relay-routed | +| [A27](#a27) | medium | | large | parity/gap | ✅ fixed in `8691e6d`, `9b5c342`, `1d8201f`, `c450e16` · Cannot connect straight to a Linux dev host or a headless Server Edition (iOS's "phone SSHes into the host" setup) | +| [A28](#a28) | medium | | small | parity/gap | ✅ fixed in `726271a,1cdd2f0` · SSH-project sessions over direct SSH are attached, approved and resumed on the wrong machine | +| [A29](#a29) | medium | | medium | parity/gap | ✅ fixed in `0c5a1e1`, `a5f38f5` · No source-control screen, although the protocol layer already implements the git verbs iOS uses | +| [A30](#a30) | medium | | small | critic/bug | ✅ fixed in `3d36d60` · Pressing Deny on the desktop is not respected: the phone re-dials about 8 s later and the SAS approval dialog reappears | +| [A31](#a31) | medium | | small | critic/bug | ✅ fixed in `a622e71` · A silently dead SSH peer (laptop asleep, desktop IP or VPN change) wedges the host as 'On your network' for many minutes: no detection, no relay fallback, and the error is never shown | +| [A32](#a32) | medium | | medium | critic/gap | ✅ fixed in `7035bde`, `88beed2` · No way to open a URL or copy text from the phone terminal: no link detection, no touch selection, and the WebView cannot show xterm's link confirm | +| [A33](#a33) | medium | | medium | critic/bug | ✅ fixed in `0db0b6e` · On a Windows computer, 'New session' starts the agent in the user's home folder instead of the project, and silently drops the chosen Claude account while still registering it | +| [A34](#a34) | medium | | small | critic/bug | ✅ fixed in `2a273a7` · The Ctrl key-row chip does not apply to text sent from the input bar: arming Ctrl and sending 'z' submits a literal 'z' plus Enter | +| [A35](#a35) | medium | | small | critic/bug | ✅ fixed in `16706f4` · approvals.answer returns `answered:false` both for 'already handled' and for 'the write failed'; the phone always says 'Already handled.' | +| [A36](#a36) | medium | | small | critic/gap | ✅ fixed in `2a273a7` · After any connection drop the terminal stays on 'Disconnected. [Reattach]' even though the host connection reconnects by itself | +| [A37](#a37) | low | | small | build/risk | ✅ fixed in `ac0923a` · proguard-rules.pro would not survive turning on minification (R8 missing-class errors) | +| [A38](#a38) | low | | small | protocol/bug | ✅ fixed in `5ff8f6c` · Quick approve requires the node to be exactly 'blocked', but the desktop publishes approval tickets while the node stays 'waiting' on a held question | +| [A39](#a39) | low | | small | protocol/bug | ✅ fixed in `437e359` · The account chip reads `account.label`, which the mirror never writes, so it falls back to the raw account UUID | +| [A40](#a40) | low | | small | runtime/bug | ✅ fixed in `a38d39d` · The pending-launch path and the attach hand-off never re-check `disposed`: a stream can stay attached forever, or a phone-started node gets registered with no agent launched | +| [A41](#a41) | low | | small | runtime/bug | ✅ fixed in `94a558a` · The composed prompt is cleared even when no stream is attached, so the text is silently lost | +| [A42](#a42) | low | | small | runtime/bug | ✅ fixed in `7debe67` · NewSessionDialog crashes if the selected project disappears while the dialog is open | +| [A43](#a43) | low | | small | runtime/bug | ✅ fixed in `2be5e87` · The HostScreen tab and scroll position reset after returning from a terminal | +| [A44](#a44) | low | | small | runtime/bug | ✅ fixed in `7988087` · System back discards Settings edits (device name, relay API base) | +| [A45](#a45) | low | | small | runtime/risk | ✅ fixed in `1901939` · No onRenderProcessGone handler on the terminal WebView | +| [A46](#a46) | low | | small | runtime/bug | ✅ fixed in `17ee526` · The ⌨ key-row chip only focuses the DOM textarea, which cannot raise the soft keyboard | +| [A47](#a47) | low | | small | runtime/bug | ✅ fixed in `52df0a3` · The Keystore decrypt runs on the main thread in the host list's composition, once per row per recomposition | +| [A48](#a48) | low | | small | runtime/bug | ✅ fixed in `d383e76`; follow-up `6afd8f5`, `a65e12f` (the seen log is keyed by computer) · The seen-events set is trimmed in hash order and updated without synchronization, which can produce duplicate notifications | +| [A49](#a49) | low | | small | security/risk | ✅ fixed in `a40d11b`; follow-up `513c166`, `402f139` (the pin is anchored in the sealed pairing answer); manual beta-19 saved-pin reuse/changed-key refusal verified at `e06b5547`, QR/relay matrix pending · SSH host-key TOFU pin is saved during key exchange (before auth) and is not tied to the pairing | +| [A50](#a50) | low | | medium | security/risk | 🟡 private beta10/code11 installed from clean e3ce041c with verified signer/APK/non-debuggable metadata; desktop-issued pairing/relay survives same-signer update and real saved relay reconnect/input pass; A91/A94 full physical empty-host flow passes, 10 Pass / 22 Partial / 32 Pending ledger and broader minified/device validation pending · Debuggable builds expose Keystore-protected credentials over adb/JDWP | +| [A51](#a51) | low | | small | security/gap | ✅ fixed in `9b4af70` · allowBackup=false does not stop device-to-device migration at targetSdk 35: hosts, pins and deviceId are cloned | +| [A52](#a52) | low | | small | security/gap | ✅ fixed in `3780f5a` · Approval and finish notifications put command text and the agent's last message on the lock screen | +| [A53](#a53) | low | | small | security/bug | ✅ fixed in `af587ac` · OSC 52 handler has no size cap (the desktop caps at 1,000,000) and setPrimaryClip is unguarded | +| [A54](#a54) | low | | small | security/bug | ✅ fixed in `32330df` · PairingClient trusts an unbounded Content-Length / EOF body from the pairing endpoint | +| [A55](#a55) | low | | medium | parity/gap | ✅ fixed in `71b592a`, `0772cbf` · Inbox and Usage are per computer; iOS merges them across all paired computers | +| [A56](#a56) | low | | small | parity/gap | ✅ fixed in `db4abccf`, `7c206ec5` (A105): original concrete hook rules, never blind `2`; physical persistence remains open · Approval cards lack iOS's "Always allow" answer | +| [A57](#a57) | low | | small | parity/gap | ✅ display fixed in `8dbeb48`; full held answers in `db4abccf` (A106) · Multi-select AskUserQuestion cards fall back to "Open session" | +| [A58](#a58) | low | | small | parity/gap | ✅ fixed in `a44f16c` · Usage and feed cards omit iOS's pace line and the context indicator on event cards | +| [A59](#a59) | low | | small | parity/gap | ✅ fixed in `57804cf`, `8020796` · No built-in dictation (iOS has on-device Whisper plus a Cloud engine) | +| [A60](#a60) | low | | small | ci-docs/bug | ✅ fixed in `67e6297` · Interop fixture needs the Electron binary, which is downloaded at test time inside the 20 s ready window, despite the workflow saying 'not Electron' | +| [A61](#a61) | low | | small | ci-docs/bug | ✅ fixed in `39e7995` · Following CONTRIBUTING / android/README (`npm ci --ignore-scripts`) wipes a desktop developer's patched node_modules | +| [A62](#a62) | low | | small | ci-docs/bug | ✅ fixed in `77c760d` · SSH transport tests fail, not skip, on macOS: the gate checks only that /usr/bin/script exists, then runs util-linux-only flags | +| [A63](#a63) | low | | small | ci-docs/gap | ✅ fixed in `b3d4c6e` · Android workflow path filters miss files that change the tested wire behavior, contrary to CLAUDE.md | +| [A64](#a64) | low | | small | ci-docs/gap | ✅ fixed in `f9782da`; actual relay-file round trip verified 2026-10-05 · Docs say the protocol tests check the mirror, the projects.list blob and the ~/.nodeterm files against desktop code, but those shapes are hand-copied in the tests | +| [A65](#a65) | low | | small | ci-docs/gap | ✅ fixed in `33af5e7` · User-facing docs and desktop UI present the Android app as working, but it has never been built by AGP or run on a device, and no device checklist exists | +| [A66](#a66) | low | | small | ci-docs/gap | ✅ fixed in `61e218b` · ANDROID_APP_URL points at a folder that exists on neither upstream nor fork main yet, and it points at source code rather than an installable | +| [A67](#a67) | low | | small | ci-docs/gap | ✅ fixed in `696fd10` · The interop fixture is excluded from every tsconfig, so `npm run typecheck` never checks it against the desktop interfaces it implements | +| [A68](#a68) | low | | small | ci-docs/risk | ✅ source-fixed in `7e91785c`; tests, mutations and exact-head CI route recorded below · Android uses main-only push + pull_request, no merge_group; manual prepare_beta=true runs five jobs | +| [A69](#a69) | low | | small | ci-docs/gap | ✅ fixed in `6f3a8da` · The Gradle/Kotlin code has no dependency-update, CodeQL or wrapper-validation coverage | +| [A70](#a70) | low | | small | ci-docs/bug | ✅ fixed in `68d5da6` · On Windows the interop tests fail with CreateProcess instead of skipping | +| [A71](#a71) | low | | small | ci-docs/gap | ✅ fixed in `39e7995` · android/README says 'JDK 17+', but the pinned Gradle 8.14.3 cannot run on JDK 25 | +| [A72](#a72) | low | | medium | critic/gap | ✅ fixed in `71290de` · Phone-started sessions are created without the agent-specific env, so for their whole life they get no hook-reply approvals, no canvas control and no pane ownership | +| [A73](#a73) | low | | small | critic/bug | ✅ fixed in `e055f37` · Notifications are documented as 'live every 8 seconds while a computer is open', but the in-app poll never posts a notification | +| [A74](#a74) | low | | small | critic/bug | ✅ fixed in `a40d11b`; follow-up `cb12f3b`, `898d937` (the relay refreshes the LAN address and host keys); manual beta-19 mismatch refuses SSH at `e06b5547`, physical relay refresh/fallback pending · The LAN leg dials a DHCP IPv4 frozen at pairing time; when another SSH host answers at that address, the host-key 'hard stop' also blocks the relay fallback | +| [A75](#a75) | low | | small | critic/gap | ✅ fixed in `437e359` · The New session account picker lists managed Claude accounts by raw UUID | +| [A76](#a76) | low | | small | critic/gap | ✅ fixed in `6966f25` · Over direct SSH, opening a Sleeping (Eco-hibernated) session lands on a bare shell with no wake or resume offer | +| [A77](#a77) | low | | small | critic/bug | ✅ fixed in `0a2a1aa` · IME insets are not handled for Android 15's enforced edge-to-edge (targetSdk 35): the terminal gets double bottom padding when the keyboard opens, and other screens have no IME padding at all | +| [A78](#a78) | medium | | small | protocol/bug | ✅ locally fixed in `bb5b3e54`; protocol658/app type-check pass; phone verification pending · Desktop quick answers can hit a prefix-matched or newly selected pane, be swallowed by copy mode, or reorder concurrent writes | +| [A79](#a79) | medium | | small | protocol/bug | ✅ locally fixed in `e64665c3`; protocol658/app type-check pass; phone verification pending · Direct-SSH quick answers can be swallowed by copy mode and an absent SSH exit status can report success | +| [A80](#a80) | medium | | small | protocol/bug | ✅ locally fixed in `0f39c33f`; protocol658/app type-check pass; phone verification pending · The control client's startup attach reply consumes the first queued command's reply slot | +| [A81](#a81) | medium | | small | runtime/bug | ✅ locally fixed in `86390a49`, `7e11e93c`; protocol658/app type-check pass; phone verification pending · Relay join and device mint can hang on stalled mobile connections and ignore coroutine cancellation | +| [A82](#a82) | medium | | medium | protocol/bug | ✅ locally fixed in `010240e0`; protocol658/app type-check pass; phone verification pending · Read-ack sweeps delete files owned by other desktops and lose acknowledgments | +| [A83](#a83) | medium | | small | build/risk | ✅ fixed in `fa71cb08`; actual release/R8 verified, full phone validation pending · AGP 8.9.1 R8 cannot parse Kotlin 2.2 metadata during a successful release build | +| [A84](#a84) | low | | small | tests/bug | ✅ fixed in `1d6b04cc`; full protocol 606/606 pass, two mutants caught · Real SSH tests share Readline state and inherit a login-shell command-not-found hook | +| [A85](#a85) | medium | | small | terminal/bug | ✅ fixed in `febe022a`; code-3 update verifies viewport/font/keyboard resizing and pre-attach tmux history; final protocol609/app type-check pass · WRAP_CONTENT WebView layout parameters force a one-row terminal despite a large native viewport | +| [A86](#a86) | medium | | medium | performance/gap | 🟡 primary drag/coast complaint user-confirmed resolved in code 7; cellular WireGuard SSH connection and smooth scrolling confirmed; Pixel drag/coast/Esc/stable-viewport new-touch stop pass, protocol658/49 gesture mutants pass; native two-mode custom-binding distance/chunk/reversal checks and three mutants pass; Pixel 7a adds bounded custom-binding/lifecycle cases, wider gesture/reversal/lifecycle/FPS matrix remains open · Scroll responsiveness is poor despite reachable tmux history | +| [A87](#a87) | medium | | medium | runtime/bug | ✅ fixed in `3cffb49d`; protocol658/type-check pass, 31 routing JS/actor/wiring mutations caught; normal drag/coast user-confirmed in beta 6; remaining device checks open · Automatic xterm reports cancel a swipe and discard queued movement | +| [A88](#a88) | medium | | small | tooling/bug | ✅ fixed in `fed68fb3`, `f5fd3821`; actual V3.0 label reproduced, 39 Python tests per SDK36/37 and ten new mutations pass; all five CI37061593216 jobs green · New SDK signer labels make private-beta verification reject the expected certificate | +| [A89](#a89) | medium | | small | runtime/bug | ✅ fixed in `c4b1f6cf`; real xterm redraw/hit-target regression and three CSS mutants pass; protocol658/type-check/code-7 delivery and Pixel continuous drag/coast/Esc/stable-viewport new-touch stop and user drag/coast confirmation pass; other device checks open · Repaint detaches the touched text span and loses continued drag/release events | +| [A90](#a90) | medium | | medium | parity/gap | ✅ implemented in `bcc92367`, `b88d1415`; protocol 684/66 and 32 mutants pass; beta 8/9 focused Pixel Home/project/custom cwd/input/history/restart/update/reconnect/exact End pass; beta 10 relay plain-shell creation/input/exact End also pass; item 32 Partial for managed/cellular variants; beta-9 Oct4 A91 empty-host flow passes · Manual SSH/WireGuard host has no way to create a new plain terminal without a local desktop/relay | +| [A91](#a91) | medium | | small | runtime/bug | ✅ fixed in `4d33a5b5`; protocol 688/67, offline app compile and six mutants pass; beta 9/code 10 installed with verified hash; beta-9 Oct4 physical empty-host first Home/exact last-End/row-group removal/connected SSH/second Home flow pass; A94 delivered full installed End/recreate/open/End flow passes · Ending the last phone shell on an otherwise empty SSH host leaves its cached row visible | +| [A92](#a92) | low | | small | desktop/bug | ✅ fixed in `127b6b28` · Desktop/Server terminal link lookup can exclude the hovered row after 32 continuing rows; 24 focused Vitest tests, full TypeScript check and three isolated mutants pass; eight native Linux Desktop cases now pass at `01b4a2f5` with A121's leave fix; eight Linux Server cases also pass at `dcdf664a`; GPU-enabled matrix Canvas4 hardware / Modal4 DOM verified; tmux8/SSH8 and separate GPU recovery pilots verified; wider GPU pressure/macOS pending | +| [A93](#a93) | medium | | medium | interop/backend | OPEN · Fresh-desktop remote-on pairing succeeds without relay credentials; bounded retry using the exact production mint request body returns HTTP 403 reauth_required; original same-desktop recovery succeeds; fresh-different-desktop failure remains open | +| [A94](#a94) | medium | | small | runtime/bug | ✅ fixed in `3c217cba`; five Kotlin methods/eight mutants and full689/67 pass, beta 10 delivered; full physical completed-empty End/recreate/open/End flow passes · Authoritative empty SSH listing remains labelled Loading sessions | +| [A95](#a95) | medium | | small | desktop/interop | ✅ fixed in `ec12ea9a`; actual beta 10 phone move/remove/re-add/create-label updates reach rebuilt production desktop Board with unchanged page time origin and matching persisted state; item 36 Pass; nine suites/133 tests, full TypeScript and four mutants pass · Held desktop Board remained stale after phone moves/labels | +| [A96](#a96) | low | | small | phone/UX | ✅ fixed in `f73633fb`; 12 bounded JVM checks / 21 isolated mutants, protocol730/70 and app checks pass; beta 11 prepared, physical follow-up pending · Sessions have no local search | +| [A97](#a97) | low | | small | parity/gap | ✅ fixed in `8443e71e`; isolated control/restored 25-method runs / 31 mutants and protocol730/70/app checks pass; beta 11 prepared, physical follow-up pending · All computers does not refresh live while visible (A55 residual) | +| [A98](#a98) | low | | small | terminal/UX | ✅ fixed in `1b1872f1`; 9 bounded JVM checks / 13 isolated mutants, protocol730/70 and app checks pass; beta 11 prepared, physical follow-up pending · Captured terminal output cannot be searched | +| [A99](#a99) | low | | small | security/risk | ✅ fixed in `e07071e9`; actual Linux control/restored 19 tests / 16 isolated mutants and protocol730/70/app/desktop checks pass; physical included-key pairing pending · SSH host-key discovery ignores recursive external Includes (A49 residual) | +| [A100](#a100) | medium | | medium | terminal/gap | ✅ source fixed in `2f693d83, 97b8a9a4, 4f1985f5`; Pixel 7a retained-history Find and selected-line clipboard paste verified on `40731381`, wider device matrix pending · Retained host history search | +| [A101](#a101) | medium | | small | runtime/bug | ✅ source fixed in `fbaa535d` · Interrupted typed session-host reads reconnect; source regression verified | +| [A102](#a102) | medium | | medium | protocol/gap | ✅ source fixed in `e584586f` · Cold relay attach retains trusted project launch settings; new physical checks pending | +| [A103](#a103) | medium | | medium | protocol/gap | ✅ source fixed in `91e6a3f5` · Agent approval policy survives launch, resume and wake; new physical checks pending | +| [A104](#a104) | medium | | medium | runtime/gap | ✅ source fixed in `2d6cb2cd` · Offscreen Sleeping nodes can wake without switching projects; new physical checks pending | +| [A105](#a105) | low | | medium | parity/gap | ✅ source fixed in `db4abccf, 7c206ec5` · Original localSettings rule verified with beta-19 Android choice and real Claude at `e06b5547`; restart persistence/wider matrix pending | +| [A106](#a106) | low | | medium | parity/gap | ✅ source fixed in `db4abccf` · Original single/nonadjacent-multi choices verified with beta-19 Android and real interactive Claude at `e06b5547`; wider matrix pending | +| [A107](#a107) | medium | | medium | parity/gap | ✅ source fixed in `489c30a8` · Typed source control works on direct SSH; new physical checks pending | +| [A108](#a108) | medium | | medium | parity/gap | ✅ source fixed in `d716138c`, `585e726f` · Owned SSH Board and Desktop session actions; new physical checks pending | +| [A109](#a109) | low | | small | tests/bug | ✅ source fixed in `4e2f877d` · Three-digit findings retain device coverage; source regression verified | +| [A110](#a110) | low | | small | tests/bug | ✅ test fixtures fixed in `8c57016c`, `b2e41255` · SSH profile isolation and actual unsafe-directory mode; merged gates verified | +| [A111](#a111) | medium | | medium | parity/gap | ✅ fixed in `d652b96c`–`ba64f628`; beta-13 local checks pass; one Pixel 7a manual-SSH plain-shell create/register/input/reopen case verified on `40731381`, live agent CLI and wider device matrix pending · New session needs genuine host-owned create/launch/register over direct SSH | +| [A112](#a112) | low | | small | tests/bug | ✅ fixed in `9675c4d1`; producer/input regressions and mutations pass · Managed producer imports session-host files missing from CI and incremental test inputs | +| [A113](#a113) | medium | | small | desktop/bug | ✅ fixed in `349b791a`; 111 affected tests / 11 assertion mutants · Legacy relay revoke lacks a confirmed remote-access receipt | +| [A114](#a114) | low | | small | phone/UX | ✅ fixed in `6b989e59`; 39 focused methods / 18 assertion mutants; physical pending · Dictation has no language picker | +| [A115](#a115) | medium | | medium | phone/bug | ✅ fixed in `0a71c028`; 73 assertion mutants and one equivalent survivor; physical pending · Replaced records and late connections retain stale preferences/credentials | +| [A116](#a116) | medium | | medium | pairing/UX | ✅ fixed in `a6b3e88b`; six real HTTP / five interop methods, 56 assertion mutants; physical pending · Multiadapter pairing/refresh has no network choice | +| [A117](#a117) | medium | | small | pairing/bug | ✅ fixed in `227a7262`; 22 reader methods, seven reader mutants and actual pairing/Android regression · Relative private HostKey names can enter Include reads | +| [A118](#a118) | medium | | medium | pairing/gap | ✅ eligible legacy association fixed in `aa902d0a`; actual host/Android proof and mutation checks pass; physical pending · Legacy entries cannot revoke their unassociated relay key | +| [A119](#a119) | medium | | small | SSH/setup | ✅ source-fixed in `3a68e42b`; actual Server/SSH and mutation checks pass; Pixel 7a manual explicit-profile Desktop SSH verified, remaining physical profile matrix pending · Explicit saved SSH profile folder for custom Server data directories | +| [A120](#a120) | medium | | medium | SSH/setup | ✅ source-fixed in `2723845e`; 17 JVM methods / seven assertion mutants, 28 native OpenSSH checks, four timing cases / 76 assertions and 17 synthetic installed-PAM checks; physical pending · One-time password setup with human fingerprint confirmation and retained-key verification | +| [A121](#a121) | low | | small | desktop/bug | ✅ fixed in `01b4a2f5`; eight native Linux Desktop cases and the synchronous-blur mutant verified · next-task Canvas blur preserves mouseleave; eight Linux Server cases also pass at `dcdf664a`; GPU-enabled matrix Canvas4 hardware / Modal4 DOM verified; tmux8/SSH8 and separate GPU recovery pilots verified; wider GPU pressure/macOS pending | +| [A122](#a122) | medium | | small | desktop/bug | ✅ source-fixed in `9613cec5`; 133 focused / 218 affected tests and six assertion-caught mutants; four Linux native cases pass · Focused Canvas/Modal xterm consumes the advertised native Quit chord | +| [A123](#a123) | low | | small | desktop/bug | ✅ fixed in `db83fb7d`; 44 budget / 301 affected tests, two helper assertion mutants and Desktop native control/sole-call-removal/restored proof pass; later genuine Server 0/3/0 and derived Desktop compositor cell evidence verified · Restored addon context remains live after disposal and budget retirement | +| [A124](#a124) | medium | | small | host/bug | ✅ fixed in `651f46da`; 38 affected tests / full TypeScript and three assertion-caught isolated mutants; one Pixel 7a manual-SSH shell creation/input/reopen/app-restart case passes on `40731381` · Managed SSH New rejects an available absolute default shell as unavailable | +| [A125](#a125) | medium | | small | desktop/bug | ✅ source-fixed in `415dae9b`; eight bounded Linux Desktop reconnect cases pass at `5b7286b3` with A126; historical negative, 13 new/100 affected tests and six mutants retained; one two-host inactive-global native case passes at `59e4c93e`; phone/wider scope open · Open SSH card does not replace its lost viewer after automatic reconnect and inactive cards resolve through the active owner | +| [A126](#a126) | medium | | small | desktop/bug | ✅ source-fixed in `5b7286b3`; 16 new/294 affected tests, full TypeScript, nine assertion mutants and eight bounded native reconnect cases pass; forced950/98/app gates pass; one quiet native park/adopt + inactive-global case passes at `59e4c93e`; wider park/Server/cross-window/phone/platform open · Co-view xterms send duplicate automatic terminal replies into the retained process | +| [A127](#a127) | medium | | small | phone/bug | ✅ source-fixed in `0d50075a`; three JVM methods/twelve callback cases/three mutants; beta 17 and beta 19 Pixel 7a neutral viewer-only exit, retained draft and same-producer manual Reattach/input verified; wider scope pending · Viewer exit falsely reports that the retained host session ended | +| [A128](#a128) | high | BLOCK | medium | input/bug | ✅ source-fixed in `7e7d2c04`; CI registration `5484ab9f`; 30 mutants, merged 982/102 protocol and app and affected 187/11/full TypeScript gates plus all-five CI pass at `a79375c3`; beta 17 emacs/vi Send exactly once, controlled vi reconnect and raw Ctrl verified; beta 19 copy-mode Send and controlled SSH-handler recovery reconfirmed; beta21 direct-SSH held and lost successful Enter receipt/newer-draft cases verified within their documented scope; exact beta-17 End and owned cleanup verified, negotiated session-host/direct-native follow-up described below, physical ConPTY and wider device scope pending · Composed Send clears a draft without submitting it while tmux history is open | +| [A129](#a129) | medium | | medium | input/bug | ✅ source-fixed in `0818bbed` / `2d693263`, published at `1add0408`; leaf 34/3 + 20 mutants, backend 343/37 + four existing Windows-only skips + 21 mutants, root 13 + 18 mutants, cleanup 1 + 1 mutant; focused Android 99/11 + 14 variants (13 behavioral/one source pin), separate CI-reader 11 + eight config deletions; fresh 1012/105 protocol, app, 568/52 Vitest with four existing Windows skips, full TS and all-five/ten-step CI `37602046133` pass; beta 18/code 19 was ready/not installed at that earlier checkpoint; beta 19/code 20 installation was an earlier checkpoint; beta 21/code 22 is now installed, native/session-host physical acceptance pending; original `ce1121ba` finding retained · Native history swipes send wheel input to the foreground instead of browsing retained history | +| [A130](#a130) | medium | | small | input/bug | ✅ source-fixed in `9da36320`, published in `5bda2c32`; three new JVM cases, 48/7 control/restored zero skips and three semantic assertion mutants; fresh 1015/106/app/TypeScript and five-job/ten-step CI pass, beta 19/code 20 installed with measured update preservation; original `1add0408` failure retained; actual app/WebView/physical acceptance pending · Held Live-button touch does not stop the previous fling until click | + +| [A131](#a131) | medium | | small | phone/bug | ✅ source-fixed/published in `11fbff08`; focused 71/8 plus ten behavioral/four app source-pin mutants, full 1029/108/app/TS and CI five/ten pass; beta 20 was installed at that checkpoint; beta 21 passes bounded active-output/background, controlled direct-SSH recovery/draft/explicit Send and A133 same-page checks; original A132 attribution and wider lifecycle acceptance remain pending · Buffered output and unready-page commands outlive their terminal viewer | +| [A132](#a132) | medium | | unknown | verification/gap | **OPEN** · Original completion marker absent after all 1200 rows; fresh normal-Fedora 40-ms raw/capture discriminator passes, historical attribution pending | +| [A133](#a133) | medium | | small | phone/bug | ✅ source-fixed in `94c8d2c4`, refined/published at `ef4caec2`; 27 renderer cases, prior semantic/pin mutants plus the delayed-Enter follow-up mutation; full 1033/110/app/TS and CI five/ten pass, beta 21/code 22 installed; bounded same-page retention, final-marker/Unicode clipboard paste and post-font OSC8/plain link offers pass; normal browser/Share pilot measured as separately scoped; wider matrix unverified · Closed SSH/tmux exit erases the last visible pane | +| [A134](#a134) | medium | | small | desktop/bug | ✅ source/caller checkpoints `3f382367`/`0d8594a6` published; seven caught mutants, full 1033/110/app/TS, affected 161/nine files/five Windows skips and CI five/ten pass; original create/Bash prompt plus fresh native 200-marker capture/reload/input continuity verified; V13 cause/fallback and native-history phone/gesture acceptance unverified · Login-shell probe can outlive its child timeout | +| [A135](#a135) | medium | | small | input/bug | ✅ source fixed; five policy/three wiring methods and three policy/one wiring mutants pass; published/installed beta22; physical timing acceptance pending · Delayed raw-input consumption can clear newly armed Ctrl | +| [A136](#a136) | medium | | small | phone/bug | ✅ source fixed; original beta21 loses draft/Ctrl on retained-entry remount; nine policy/three wiring methods and seven policy/one wiring mutants pass; published/installed beta22; one no-Send remount retains draft/Ctrl, broader acceptance pending · Retained terminal entry loses its editor when composition is removed | +| [A137](#a137) | medium | | small | input/bug | ✅ source fixed; twelve behavior/three wiring methods, ten behavior/three wiring mutants, full1069/116/offline app and exact-head CI five/ten pass; published `b9e4cbc7`, beta23/code24 prepared; install/phone unverified; no physical duplicate or automatic retry claim · Retained terminal remount loses pending Send state and outcome guidance | + +## A01 + +**Direct-SSH terminal writes and resizes run on the Android main thread, which breaks the SSH connection as soon as the keyboard opens** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: small; area: build; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt:221` +- Note: Same bug as A04 (reported by two auditors). Fix once, in SshStream. + +**Evidence** + +SshStream.write/resize are plain (non-suspend) calls that write to the socket synchronously: `override fun write(text: String) { synchronized(this) { runCatching { stdin.write(...); stdin.flush() } } }` (l.221-228) and `override fun resize(...) { runCatching { (session as Session.Shell).changeWindowDimensions(...) } }` (l.230-233). TerminalController calls them on the MAIN thread: `main.post { ... if (s != null) s.resize(c, r) ... }` (TerminalController.kt l.124-132, fired by xterm's fit whenever the WebView resizes, and TerminalScreen.kt l.77 `.imePadding()` / adjustResize resize it when the soft keyboard opens), `main.post { ... s.resize(cols, rows) }` (l.216), `fitHere()` (l.279), `acceptResume()` -> `stream?.write` (l.270), and `raw()` -> `stream?.write` (l.290) from the ^C/^D/^R/^L key chips (TerminalScreen.kt l.163-166). On Android, socket writes on the main thread throw NetworkOnMainThreadException, a RuntimeException. `javap` of sshj 0.39.0 `TransportImpl.write` shows `Encoder.encode` (offset 108) runs before `OutputStream.write/flush` (131/141). The only handler around the socket write is `java/io/IOException` (range 112-144), and `Encoder.encode` does `putfield seq`, so the sequence number and cipher state have already advanced. The exception escapes sshj and SshStream's runCatching swallows it silently. The next packet (the user's next keystroke, from the JavaBridge thread) is encrypted with a state the server does not expect, so sshd drops the connection. The terminal ends with 'Disconnected.', and the shared SSHClient used for listing and the Inbox dies with it. The relay path is unaffected: RelayHostConnection.Stream.write/resize only enqueue on OkHttp's `ws.send`. The same NetworkOnMainThreadException hits `SshHostConnection.close()` -> `client.disconnect()` when it runs from click handlers (HostsScreen.kt l.145 Forget, SettingsScreen.kt l.109 route change). There it escapes sshj's `sendDisconnect` before `finishOff()`, which leaks the socket and the sshj reader thread. JVM tests cannot see any of this because the JVM has no BlockGuard, and nothing in app/ or protocol/ relaxes StrictMode. + +**Proposed fix** + +Keep all sshj I/O off the main thread while preserving write order. Give SshStream a single-thread writer, e.g. `Executors.newSingleThreadExecutor()`, and have write/resize enqueue onto it. Alternatively, have TerminalController route every stream.write/resize/raw/acceptResume/fitHere call through one serial off-main dispatcher, such as `Dispatchers.IO.limitedParallelism(1)`. Run `HostSession.disconnect()` / `SshHostConnection.close()` on `Dispatchers.IO` as well. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line numbers and root cause are accurate. Three refinements: +> (a) One more main-thread caller of `close()`: `PairScreen.kt:152` (`graph.connections.forget`), alongside `HostsScreen.kt:145` and `SettingsScreen.kt:109`. `InboxNotifier.kt:127` runs in the worker and is fine. +> (b) Moving sshj writes off the main thread matters even apart from StrictMode. `TransportImpl.write` takes `writeLock` and can block in `kexer.waitForDone()` during a rekey. `ChannelOutputStream.flush` can block in `win.awaitExpansion`. On the main thread either one is an ANR risk. +> (c) The fix must preserve ORDER across two producers: main-thread resize/raw/acceptResume and JavaBridge-thread onInput. So route both `SshStream.write` and `resize` through ONE serial executor inside `SshStream` (e.g. a single-thread executor created per stream and shut down on detach/exit). Fixing each call site separately would leave the ordering unguaranteed. Also make `HostSession.disconnect()` run `c?.close()` on `Dispatchers.IO` (e.g. `graph.scope.launch(Dispatchers.IO)`). For defense, catch `RuntimeException` around `client.disconnect()` and fall back to closing the socket, so a failed `SSH_MSG_DISCONNECT` cannot leak the socket and reader thread. + +> The finding is accurate. Four refinements: +> +> 1. The finding lists the keyboard, rotation, A−/A+ and 'Fit this screen' as triggers. The A−/A+ path is the only one that fires even with a hardware keyboard and no rotation, because TerminalController.setFontSize -> terminal.js `setFontSize` -> `doFit(true)` forces onResize. +> +> 2. What gets corrupted is broader than the sequence number. Encoder.encode advances both `seq` (putfield at offset 274) and the cipher stream (Cipher.update at 334/381), so every negotiable cipher desyncs. +> +> 3. For close(): the DisconnectListener fires before the failing sendDisconnect, but it is a no-op because close() already set closedFired=true. The keepalive is interrupted first, so the leaked socket is idle rather than kept alive. +> +> 4. Put the fix in the protocol layer, not in TerminalController. The TerminalStream contract is non-suspend and callers cannot know that SSH blocks. Give SshStream a single-thread executor and route write, resize and scroll through it; this also serializes main-thread raw()/resize with JavaBridge-thread onInput, keeping them in order. Make SshHostConnection.close() hand client.disconnect() to that executor, or to a background thread. For defense in depth, catch RuntimeException in SshStream by closing the channel/transport instead of silently swallowing it, since the transport is unusable after any exception thrown mid-write. + +## A02 + +**SSH browse looks for the desktop's userData under 'nodeterm', but the desktop writes it under 'node-terminal', so every direct-SSH listing comes back empty** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: small; area: protocol; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt:38` +- Note: Related: A27 (the Server Edition data dir is not probed either). + +**Evidence** + +Android PRELUDE: `for d in "$HOME/Library/Application Support/nodeterm" "${XDG_CONFIG_HOME:-$HOME/.config}/nodeterm"; do if [ -f "$d/workspace.json" ]; then NT_UD="$d"; ...`. On the desktop, userData is `app.getPath('userData')`, which is appData/. package.json has `name: "node-terminal"` and NO top-level productName (only `build.productName: "nodeterm"`). electron-builder's `modifyMainPackageJson` (node_modules/app-builder-lib/out/fileTransformer.js:88-106) merges only `config.extraMetadata` into the packaged package.json, and this repo sets none, so at runtime app.name is `node-terminal`. The desktop's own runtime shell agrees: src/core/agents/hook-endpoint-failover-sh.ts:79-80 walks `"$HOME/.config/node-terminal/hook-endpoint.env"` and `"$HOME/Library/Application Support/node-terminal/hook-endpoint.env"`, and node-token-sh.ts:93-94 uses the same `node-terminal` dirs. Repo tests use the same path as a realistic example (ssh-agent.test.ts:59, codex-relay-daemon.test.ts:925). The mirror is written to `path.join(platform().userDataDir, 'agent-status.json')` (agent-status-mirror.ts:1436). Only scripts/uninstall.sh and docs/uninstall.md say `nodeterm`, and the Android prelude copied that. I ran the prelude under /bin/sh against a fake HOME that had `~/.config/node-terminal/workspace.json`: it printed `ud=` (empty). SshTransportTest.kt:154 passes only because its fixture creates `.config/nodeterm`. + +**Proposed fix** + +Probe `$HOME/Library/Application Support/node-terminal` and `${XDG_CONFIG_HOME:-$HOME/.config}/node-terminal` first, keeping `nodeterm` as a fallback. Change the SshTransportTest fixture to the real directory name so the test would have caught this. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The core claim and root cause are correct. Two refinements: +> +> 1. The fix should probe `node-terminal` first on both platforms: `$HOME/Library/Application Support/node-terminal` and `${XDG_CONFIG_HOME:-$HOME/.config}/node-terminal`. Keep the existing `nodeterm` dirs as fallbacks. Also consider `$HOME/.nodeterm-server`, the Server Edition's default data dir (src/server/config.ts:108), which the prelude does not probe either. Update the header comment at SshScripts.kt:17-19 and the SshTransportTest fixture (line 154) to `node-terminal`. +> +> 2. docs/uninstall.md:24 and scripts/uninstall.sh:64 carry the same wrong `nodeterm` path. That is a separate desktop-side doc/script bug, but it is where the Android prelude copied the path from. + +> Mostly correct. A few refinements: +> +> (a) The KDoc at SshScripts.kt:18-19 states the wrong path too, and should be fixed along with line 38. +> +> (b) The `nodeterm` spelling appears in more places than scripts/uninstall.sh and docs/uninstall.md. It is also in the src/main/remote-ssh/ssh-agent.ts:76-77 comment, which wrongly says the installed app is "nodeterm". Illustrative paths use it in docs/codex-shared-identity.md:72 and in claude-accounts-core.test.ts / claude-skill-share-core.test.ts. The desktop repo is internally inconsistent, and the Android author followed the wrong half. +> +> (c) The proposed fix is right. Probe `node-terminal` first in both the macOS and XDG locations, keep `nodeterm` as a fallback, and change the SshTransportTest.kt:154 fixture to `.config/node-terminal` so the test catches this. +> +> (d) Not strictly needed, but: because the listing is empty rather than failing, ConnectionManager never falls back to the relay even when one is paired. A browse that returns `ud=` with no workspace should be treated as a failed or degraded SSH route rather than a legitimately empty host. + +## A03 + +**The direct-SSH tmux client starts without a UTF-8 locale, so tmux replaces every non-ASCII character with '_'** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: small; area: protocol; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt:92` + +**Evidence** + +The attach script sets only `TERM=xterm-256color; export TERM` and then `exec "$NT_TMUX" -L node-terminal "$@" new-session -A -s ...`. It never sets LANG/LC_ALL and never passes `-u`, and nothing in the Android code calls sshj `setEnvVar` (grep for LANG/LC_ALL/setEnvVar/` -u ` in android/ finds nothing). tmux marks a client UTF-8-capable only when LC_ALL/LC_CTYPE/LANG contains UTF-8 (or with -u). The desktop solves this for its own clients: pty-manager.ts:512 `resolveLocaleLang()` forces `en_US.UTF-8` into the spawn env ('rounded borders come out as `_`/`|`'), and passes `-e LANG=` per session (pty-manager.ts:3249-3251). An sshd exec channel on a stock macOS host carries no LANG: sshj sends no env, and the `zsh -c` wrapper sources no locale. Measured in the sandbox with tmux 3.4: a client started without LANG received `_ box` instead of `╭ box`; the same client with LANG=en_US.UTF-8 received the real glyphs. + +**Proposed fix** + +In the attach script, export a UTF-8 locale when the inherited one is not UTF-8 (the same rule as resolveLocaleLang), or add `-u` to the tmux client invocation. Add an SshTransportTest case that runs with LANG unset. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line and root cause are correct (SshScripts.kt:92-93, with the exec on line 93). Two refinements: +> +> 1. Not every character becomes `_`. Box-drawing characters that have a DEC ACS mapping (─ │ ┌ ┐ └ ┘ and the like) are converted by tmux to `ESC(0` + an ACS letter, and xterm.js renders those correctly. Characters with no ACS mapping become width-many underscores: rounded corners ╭╮╰╯, accented Latin letters, CJK (two underscores each), and symbols and emoji such as ✻ ⏺ ⎿. +> +> 2. The better fix is to export a UTF-8 LANG in the attach script, using the desktop's `resolveLocaleLang` rule (keep an inherited LC_ALL/LC_CTYPE/LANG that already says UTF-8, otherwise `LANG=en_US.UTF-8`), rather than only adding `-u`. `-u` fixes this client's rendering but not the pane environment when the phone's attach starts the tmux server; exporting LANG covers both. Adding `-u` on top is harmless. +> +> The regression test should attach under an env with no LANG, LC_ALL or LC_CTYPE and assert that a non-ASCII glyph (e.g. `é`) arrives as UTF-8 bytes, not `_`. + +> The line is right (92-93) and so is the root cause. Two additions and a sharper fix: +> +> (a) The impact is mainly stock macOS hosts, plus Linux hosts without a pam_env locale. Debian/Ubuntu normally get LANG through PAM. +> +> (b) SshTransportTest.kt:57 hides the bug by passing the JVM's own environment, LANG included, to the fake sshd. The new test must clear LANG, LC_ALL and LC_CTYPE in `childEnv()` and assert that a non-ASCII glyph such as `╭` comes through the attach. +> +> (c) Fix: add `-u` to the tmux client invocation in `attach()`. That makes the client UTF-8 whatever locale the host has installed. Also export LANG only when LC_ALL, LC_CTYPE and LANG are all non-UTF-8, so that panes of a server the phone starts don't inherit a C locale. Use `en_US.UTF-8` on Darwin, where it is always present and matches the desktop's resolveLocaleLang, and `C.UTF-8` elsewhere. Forcing en_US.UTF-8 on a Linux host that lacks it makes shells print setlocale warnings. `-u` alone fixes rendering but not the pane locale of a phone-created server. + +## A04 + +**SSH terminal writes and resizes run on the main thread, and the NetworkOnMainThreadException that runCatching swallows leaves sshj's cipher state out of sync, which drops the whole SSH connection** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:131` +- Note: Same bug as A01. + +**Evidence** + +Main-thread callers of TerminalStream.write/resize: +- TerminalController.kt:125-131: `main.post { ... val s = stream; if (s != null) s.resize(c, r) ... }`. This runs on every WebView resize: the soft keyboard (TerminalScreen.kt:77 `Column(...imePadding())` shrinks the WebView), rotation, and A−/A+ (`nt.setFontSize` → `doFit(true)` → `bridge.onResize`). +- :216 `main.post { ... s.resize(cols, rows) }`; :279 fitHere `s.resize(cols, rows)`; :270 acceptResume `stream?.write(offer.second + "\r")`; :290 `fun raw(data: String) { stream?.write(data) }`, which the ^C/^D/^R/^L chips call (TerminalScreen.kt:163-166). + +SSH side, SshHostConnection.kt:221-233: `synchronized(this) { runCatching { stdin.write(...); stdin.flush() } }` and `runCatching { (session as Session.Shell).changeWindowDimensions(...) }`. Both write to the socket on the calling thread. + +In the sshj 0.39.0 jar, TransportImpl.write calls `Encoder.encode(packet)` at bytecode offset 108; that increments the sequence number and runs cipher.update/MAC. Only then does it call `connInfo.out.write` at offset 131 (a raw java.net.Socket stream, from SocketClient.onConnect). The exception table catches only IOException. + +On Android the main thread has StrictMode death-on-network (targetSdk ≥ 11), and SocketOutputStream.socketWrite calls `BlockGuard.getThreadPolicy().onNetwork()`. So the write throws NetworkOnMainThreadException, a RuntimeException that runCatching silently swallows. + +**Proposed fix** + +Never call TerminalStream.write/resize on the main thread. Either give SshStream a per-stream single-thread executor that does write+flush and changeWindowDimensions (this keeps keystrokes in order), or route raw/acceptResume/fitHere/onResize/the attach-time resize through a serial background dispatcher (e.g. Dispatchers.IO.limitedParallelism(1)). Also stop runCatching-ing a RuntimeException out of sshj: at that point the transport state is corrupt, so close the connection. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The substance is correct. Three refinements: +> +> (a) Ordinary typed input is NOT affected. `Bridge.onInput` (`TerminalController.kt:136-144`) runs on the WebView's JavaBridge background thread, and so do `submit()`/`key()`, which go through `nt.submit`/`nt.key` → `bridge.onInput`. Only these main-thread paths break: the resize at `:131` and `:216`, `fitHere` at `:279`, `acceptResume` at `:270`, and the KeyRow chips at `:290`. Opening the keyboard alone is enough, because `adjustResize` plus `imePadding` shrinks the WebView, xterm's rows change, and `onResize` fires. +> +> (b) The fix has to serialize with the JavaBridge-thread `onInput` writes as well. Otherwise ^C from a chip could overtake or interleave with typed bytes. So put the executor or `limitedParallelism(1)` dispatcher inside `SshStream` itself, covering write+flush and `changeWindowDimensions`, rather than in the controller. +> +> (c) `runCatching` in `write`/`resize` also hides an `IOException`/`TransportException` from a dead socket. Any exception out of `Transport.write` should close the stream or connection, not be ignored. + +> The finding is accurate. Three refinements: +> +> (a) Line 235 (afterAttach) is NOT on the main thread: it runs on graph.scope, which is Dispatchers.Default. Typed keystrokes via Bridge.onInput (:143) run on the WebView JavascriptInterface thread and are also unaffected. The main-thread offenders are exactly :131, :216, :270, :279 and :290. +> +> (b) The disconnect does not need further user activity. After one swallowed main-thread write, the next packet on the transport breaks the connection. That can be the HostSession poll's exec (every 8 s) or sshj's keep-alive (set to 20 s at SshHostConnection.kt:353). So the terminal drops within about 8 to 20 s of the first keyboard-open, rotation, A−/A+, chip tap, Resume or Fit. +> +> (c) Fix details: +> - Move SshStream.write and resize onto one per-stream single-thread executor (or a `Dispatchers.IO.limitedParallelism(1)` dispatcher) so writes and resizes keep their order. +> - On any non-IOException thrown from sshj's write path, close the connection: `client.disconnect()` plus `fireClosed`. Do not swallow it. +> - Narrowing runCatching to IOException alone is not enough: the cipher has already advanced before the throw. +> - Optionally, move the controller's main.post resize and raw calls off the main thread as well, which protects any future TerminalStream implementation. + +## A05 + +**The background InboxWorker can open a never-approved relay handshake and raise the desktop's SAS approval dialog while the phone shows no code** + +- Severity: **high** (BLOCKS first release); claimed by auditor: medium; effort: small; area: runtime; kind: risk +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:124` +- Note: Same issue as A17 and A23 (three auditors). Related: A07, A30. + +**Evidence** + +- NodetermApp.kt:45 schedules InboxWorker on every app start. +- InboxNotifier.kt:121-127 runs `withTimeoutOrNull(45_000) { session.refreshNow() }` for every paired host. +- refreshNow → connectLocked (ConnectionManager.kt:99-125) falls through to the relay when SSH fails (phone off the LAN). It then runs RelayConnector.connect, which holds the handshake open and polls projects.list while the desktop waits for approval (RelayConnector.kt:67-86). +- Pairing never pins the phone's box key: the PairingClient body is `{token, publicKey(ssh), deviceName, deviceId, priorDeviceToken}`. standing-host.ts:12-13 says "the first connect from a given phone (its box public key) prompts the host human via the shared SAS dialog". +- A phone paired on the LAN with route AUTO connects over SSH in the foreground, so its first relay connect is usually the worker's, in the background. +- The SAS is rendered only in HostScreen's ConnectionBanner. + +**Proposed fix** + +Store a per-host "relay approved" flag after the first successful foreground relay connect, and have the worker skip the relay leg until it is set. Alternatively, abort on RelayConnectStatus.AwaitingApproval in background mode and post a notification asking the user to open the app. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause is right. The second proposed fix is not: aborting on `RelayConnectStatus.AwaitingApproval` in background mode would not prevent the desktop prompt. The desktop raises its SAS dialog in `onPeerReady`, as soon as the E2EE handshake completes (`standing-host.ts:256-261`). The phone only learns it is awaiting approval later, when its first `projects.list` fails with "Awaiting host approval." (`RelayConnector.kt:72-80`). Even if the phone closed immediately, the desktop would keep the approvable dialog for 120 s (`phone-approval.ts`). +> +> The fix that actually prevents the prompt is the first proposal: never enter the relay leg from the background worker until a foreground relay connect for that host has completed. Record a per-host "relay approved" flag when `RelayConnector.connect` returns successfully in the foreground, and have `InboxWorker` pass a background/`allowRelayHandshake=false` mode to `connectLocked`. +> +> Optionally, when the worker skips a host whose relay leg is unapproved, it can post a local notification asking the user to open the app and approve this phone. One more precision: the prompt does not repeat forever. It recurs every 15 minutes only while the phone is off the LAN and the key is still unpinned. + +## A06 + +**approvals.answer reports success for a hold that already timed out, and clears NEEDS YOU on every surface** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: small; area: security; kind: bug +- Location: `src/main/remote/host-service.ts:690` +- Note: Related: A35 (answered:false is ambiguous). + +**Evidence** + +The new verb's doc (host-service.ts:690) promises "`answered:false` is an ANSWER (the hook already timed out, the host could not write)". It cannot return that for a timeout. The relay leg reaches `answerPermission` (src/main/index.ts:2774), which calls `writePendingAnswerLocal` (src/core/agents/pending-approvals.ts:42-58: `mkdir` + `writeFileAtomic(file, decision)`, then `return true`). The SSH-project leg is `ssh-project.ts:1823-1845` (`remoteAtomicWrite`, then `code === 0`). Neither checks that `.json` still exists. The hook deletes that file and stops polling when its hold ends: managed-script.ts:423-425, `# Timed out: clean up the request + payload files and print nothing → Claude shows its normal prompt`. On `ok`, answerPermission then emits `syntheticAnsweredEvent`, so the mirror marks the card resolved and flips the node to working (agent-status-mirror.ts:1492-1496). The phone's own SSH leg does check: SshScripts.kt:136 `if [ ! -f "$d/$pendingId.json" ]; then echo gone; exit 0; fi`. On timeout the hook POSTs nothing, so the inbox card keeps its dead `pendingId` (only a `working` event with that id resolves it). QuickActions.stillWaiting still sees BLOCKED and takes the relay path. The hold is `PERM_WAIT_SECS_DEFAULT = 45` (hook-server.ts:65). Android's only background alert path is the 15-minute WorkManager poll (InboxNotifier.kt:64). So an Approve or Deny tapped from an Android notification almost always arrives after the hold has ended. + +**Proposed fix** + +In answerPermission, check that the request still exists before writing: locally, `fs.access(/.json)`; over SSH, a `test -f` in the same remote command. Return false otherwise, and skip the synthetic event on false. On the phone, map a 'gone/false' result for a node that is still BLOCKED to OPEN_SESSION (the prompt is now on screen) rather than 'Already handled.' + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The core claim is right. Some details need adjusting: +> +> 1. **Where the tap happens.** The Android notification has no Approve/Deny actions; it only sets a content intent that opens the app (InboxNotifier.kt:97-113). Approve/Deny live in the Inbox tab (InboxTab.kt:111-114). So the late answer comes from a user who opens the app, from that notification or just by browsing, and taps a card that is still unresolved. The 15-minute poll (InboxNotifier.kt:64) is why that usually happens after the 45 s hold, but any in-app tap more than 45 s after the request hits the bug. +> +> 2. **The gap is not new, but its reach is.** The writer was extracted unchanged from the pre-existing canvas IPC handler in commit a0c07e6. The desktop canvas Approve/Deny has always had the same problem: the renderer keeps `pendingId` on blocked (agentStatus.ts:558), so the buttons stay after a timeout. On the desktop, though, the prompt is on screen in front of the user. What commit a0c07e6 adds is exposure to a remote phone, plus a doc/test contract (host-service.ts:690, host-inbox-verbs.test.ts:6-7) that the code cannot meet. +> +> 3. **Severity.** High is defensible, because a phone answer usually lands after the hold. Medium would also be defensible, since it only bites when the phone answers after the 45 s hold. +> +> 4. **The fix.** The proposed fix is right in substance, with these points: +> - Check `/.json` exists before writing, locally and in the same remote command over SSH. +> - Return false and skip the synthetic event when it is missing. +> - A small race between the check and the write remains, as it does in SshScripts.kt:136. It is harmless apart from an orphaned `.answer` file that the sweep removes. +> - On the phone, prefer OPEN_SESSION over "Already handled." when `answered:false` comes back and the node is still BLOCKED, because the interactive prompt is then on screen. + +> 1. **Approve/Deny are not notification actions.** The notification built at `InboxNotifier.kt:95-117` only has a content intent that opens the app. The buttons are in the Inbox tab (`InboxTab.kt:109-117`), reached after opening the app or a notification. The timing argument still holds. +> +> 2. **The root cause is older than this branch.** Commit a0c07e6 only extracted the canvas IPC body into `answerPermission`. The canvas Approve/Deny button (`IPC.agentAnswerPermission`) has had the same no-existence-check behaviour all along. The new relay verb inherits it and documents a `false` result it can never produce. What this branch adds is exposure: the phone's minutes-late tap is now the common case. +> +> 3. **Severity is arguably medium-high rather than high.** It is limited to relay-only Android phones answering Claude hook-reply approvals. Within that path, it is the dominant outcome. +> +> 4. **The fix belongs in the writers:** +> - `writePendingAnswerLocal`: `fs.promises.access(path.join(dir, pendingId + '.json'))` before writing; return false if it is missing. +> - `SshProjectManager.writePendingAnswer`: put `test -f /.json || exit 3` in the same remote command. +> - `answerPermission` already skips the synthetic event when the result is false. +> - A small race remains: the hook can time out between the check and the write. Closing it fully would need the hook to POST a "timed-out" event, which the mirror would use to strip the `pendingId` from the card. That would also let the desktop badge stop offering dead Approve/Deny buttons. +> +> 5. **Map the Android dead-ticket results to OPEN_SESSION.** Both the relay `answered:false` and the SSH `gone` currently map to ALREADY_HANDLED. When the fresh status still says BLOCKED, the prompt is now on screen, so the result should be OPEN_SESSION. A claude-only legacy `1`/Esc send-keys would also work there, but only after re-confirming the pane. + +## A07 + +**Remote access fails the first time you are away from the computer: Auto never does the first relay handshake while the phone is at the desk, and pairing does not pin the phone's relay key** + +- Severity: **high** (BLOCKS first release); claimed by auditor: high; effort: medium; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt:76` +- Note: Related: A05, A30. Partly a desktop design gap (the relay key is never pinned at pairing), so iOS likely shares it. + +**Evidence** + +connectLocked tries direct SSH first whenever `route != RELAY_ONLY && host.sshAvailable` (lines 76-87) and returns on success. The relay is only tried when SSH fails (lines 99-125). After pairing, PairScreen goes straight to Route.Host, which connects over SSH on the LAN. adoptRelayIfAdvertised returns early when a relay leg and token already exist (ConnectionManager.kt:167). So a phone paired on the LAN never does a relay handshake while its owner is at the computer. Nothing else can pre-approve it. The /pair body the phone sends is `{token, publicKey(ssh), deviceName, deviceId, priorDeviceToken}` (PairingClient.kt:45-50), with no box key. The desktop /pair handler never calls pinDevice (grep: pairing-service.ts has no approved/pin reference). The standing host approves a relay peer only if `isPinned(store, pub)` (standing-host.ts:250); otherwise it raises the SAS dialog (standing-host.ts:258). The first relay connect therefore happens off-LAN, usually with nobody at the desktop. RelayConnector waits 5 minutes and then fails with "Nobody approved this phone on your computer in time." (RelayConnector.kt:82). Meanwhile PairScreen told the user "✓ Reachable from anywhere (remote access is on)" (PairScreen.kt:123). + +**Proposed fix** + +Complete the pin-once approval while the user is at the computer. Either (a) run one relay handshake plus approval right after pairing (and after late adoption), while the phone is still on the LAN, and show the SAS on PairScreen; or (b) send the phone's persistent box public key inside the E2EE-sealed /pair body and have the desktop pin it when pairing succeeds. Until then, PairScreen should say that remote access needs one approval at the computer. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The behaviour is as described, but four things need adjusting. +> +> (1) Root cause and scope. This is not only an Android client bug. The desktop design itself has no way to pin a phone's relay key at pairing time. The desktop's own documented flow in the standing-host.ts:424-434 comment (put the phone on cellular, "FIRST connect: the desktop shows the SAS approval dialog") treats the first relay connect as the approval moment. So iOS very likely has the same gap; that part is inferred, since the iOS repo is not visible. android/README.md:15 even documents it ("Pin-once: approve the phone on the computer the first time"). +> +> (2) Severity. Medium fits better than high. It is one-time setup friction, not a broken steady state. Workarounds exist: switch the route to Relay-only in Settings (SettingsScreen.kt:104) or connect over cellular while at the desk; approving within the 5-minute wait or the 120 s retention after the phone gives up also pins the key. Windows hosts are not affected, because they have no SSH and their first connect after pairing goes through the relay while the user is still at the desk. +> +> (3) A related side effect. While the owner is away, the background inbox poll (InboxNotifier.kt:122-124) runs refreshNow under a 45 s timeout. An unapproved phone therefore raises the SAS dialog on the unattended desktop, then gives up on that attempt after 45 s — repeated noise, never an approval. +> +> (4) Choice of fix. Fix (a) needs only an Android change, and it is enough: after pairing, and after late adoption, run one relay handshake plus approval while still on the LAN, and show the SAS on PairScreen. Fix (b) is cryptographically sound, because the /pair body is sealed to the QR's host key and gated by the one-time QR token. But it needs a desktop change in pairing-service.ts, and it changes the phone trust model that docs/ios-protocol-migration.md §7 leaves as an open question to settle with iOS. At minimum, PairScreen.kt:123 should say that remote access needs one approval at the computer. + +> Facts and line numbers are correct. +> +> Severity should be medium, not high. This is the desktop's documented pin-once design (standing-host.ts:12-14 and the smoke test at 426-434; docs/android.md:23). The iOS app very likely shares it, so it is a real, inherited product gap rather than an Android-only bug that breaks a feature. It is also a one-time cost: approving once from the desk with the phone off the LAN, or with the route set to Relay-only, fixes it permanently. The problem is that nothing tells the user this. +> +> Root cause: the only way a phone key gets pinned is the standing host's interactive SAS approval (standing-host.ts:180/250), and the Android route order means that approval never happens while the user is at the computer. +> +> Better fix order: +> (a) Android-only, no desktop or iOS change needed. After a successful pair that returned a relay leg (and after late adoption in adoptRelayIfAdvertised), run one RelayConnector.connect while the user is still at the desk and show the SAS on PairScreen. Use the same flow as HostScreen's AwaitingApproval banner, then close that connection. The user stays at the desk until they approve or skip. +> (b) The desktop-side option is sound but must land together with iOS. It would add the phone's persistent box public key to the E2EE-sealed /pair body. That body is authenticated by the on-screen one-time token and sealed to the QR's hostKey. pairing-service would then pin that key through updateApprovedDevices/pinDevice. +> +> Interim: PairScreen:123 should say remote access needs one approval at the computer, rather than "✓ Reachable from anywhere". +> +> Secondary: InboxWorker should not start a relay approval in the background. When state would be AwaitingApproval and nobody is watching, it should skip, so it does not raise unattended SAS dialogs on the desktop every 15 minutes. + +## A08 + +**A cold attach over direct SSH creates the desktop's tmux session with no hook environment, so an agent resumed there never reports status, and the desktop never repairs it** + +- Severity: **high** (BLOCKS first release); claimed by auditor: medium; effort: small; area: critic; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt:93` +- Note: Related: A72 (phone-started sessions lack agent env). + +**Evidence** + +SshScripts.attach runs `exec tmux -L node-terminal [-f conf] new-session -A -s nt- [-c cwd]` (line 93) with no `-e NODETERM_NODE_ID=… -e NODETERM_HOOK_ENDPOINT=…`. When the session is gone (the reboot case the app detects via `hasSession`, SshHostConnection.kt:172), this CREATES it bare. The managed hook script is gated on the variable (`if [ -z "$NODETERM_NODE_ID" ]; then` exit — managed-script.ts:153). An agent started in that pane, including via the phone's own cold-start 'Resume' offer (TerminalController.kt:247-254), therefore reports nothing. When the desktop later mounts the node, the session already exists, so `fresh:false`: no cold restore, and tmux ignores `-e` for an existing session. The relay path does not have this problem, because the desktop's attachDetached injects the hook env from persistKey (pty-manager.ts:2892-2896). + +**Proposed fix** + +Do not cold-create over SSH. When `hasSession` says no, offer to 'open on the computer' via the relay, or ask the user to open the node on the desktop first. At minimum, pass the hook env the desktop would pass (NODETERM_NODE_ID, NODETERM_HOOK_ENDPOINT pointing at `/hook-endpoint.env`, NODETERM_HOOK_VERSION) with `-e` when creating. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause and the file:line are correct (`SshScripts.kt:93` has no `-e` hook env when `new-session -A` creates the session). The impact statement is incomplete. The session is only fully "dark" when the phone's attach is what starts the tmux server. When the desktop's tmux client started the server, the bare session inherits the tmux GLOBAL env, which the desktop seeded with another node's `NODETERM_NODE_ID`, `NODETERM_HOOK_ENDPOINT` and possibly `NODETERM_AGENT_ID` / `NODETERM_CANVAS_CONTROL` (`pty-manager.ts:2897` merges `hookEnv` into the client process env, and `NODETERM_*` is not in `update-environment`). That was verified with tmux 3.4. The resumed agent's status, verified token and session id are then attributed to the wrong node, and `Canvas.tsx:13242` overwrites that node's persisted `sessionId`. +> +> This is also an iOS parity gap. iOS passes `NODETERM_HOOK_ENDPOINT` and `NODETERM_NODE_ID` (`phone-spawned-identity.test.ts:4`, `docs/node-identity.md:185-187`). Severity is at least medium, arguably high, given the cross-node corruption. +> +> Better fix: when creating, ALWAYS pass `-e NODETERM_NODE_ID=` and `-e NODETERM_HOOK_ENDPOINT=/hook-endpoint.env`, as iOS does. This also overrides any leaked global value. For agent nodes, also pass `-e NODETERM_AGENT_ID=`. For a plain terminal, unset the inherited agent/canvas-control vars; `-e` cannot unset, so wrap the command with `env -u NODETERM_AGENT_ID -u NODETERM_CANVAS_CONTROL` or similar. The node id alone is enough to heal the endpoint through the hook's failover (`managed-script.ts:146-152`). `-e` is safe to send on every call because it is ignored when the session already exists. + +## A09 + +**Nodes of SSH projects open against the desktop's LOCAL tmux, creating an empty phantom session and offering to resume the conversation on the wrong machine** + +- Severity: **medium** (BLOCKS first release); claimed by auditor: medium; effort: small; area: protocol; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt:141` +- Note: Same issue as A28. + +**Evidence** + +SessionsTab lists every project's sessions, including ones labelled "on user@host" (sshTarget != null), and `onClick` pushes Route.Terminal for any node. Over SSH, SshScripts.attach runs `tmux -L node-terminal new-session -A -s nt-` on the DESKTOP. Over the relay, host-service calls `attachDetached(nodeId, sinks, { cols, rows })` with no sshRemote/requireRemote; pty-manager.ts:2254-2259 says the relay host 'passes only {cols, rows}', so the spawn is local. Neither path finds the session, so `fresh=true`, and TerminalController:253 offers `claude --resume ` in a local shell. CLAUDE.md states 'A remote node is NEVER spawned locally'. The desktop expects phones to reach those sessions on their own host: the mirror pushes per-project slices to `~/.nodeterm/agent-status-.json` on the SSH host (docs/mobile-usage-inbox.md), and index.ts's ack sweep notes 'a Mac→SSH node's acks land on the REMOTE fs'. For the same nodes, SshScripts.answerApproval checks for `$HOME/.nodeterm/pending/.json` on the desktop, but the managed hook writes it on the SSH host, so the phone prints `gone` and reports ALREADY_HANDLED for a live approval. + +**Proposed fix** + +Until the phone can reach the SSH host itself, disable open/resume for nodes whose project has an sshTarget (say why in the row). On the SSH transport, route approvals for those nodes to the relay verb (the desktop writes over the ControlMaster) or show "open on computer". + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Three small corrections, plus a better fix: +> +> - **"Neither path finds the session, so fresh=true" is not always true.** On the relay, `sessionExists` first checks `liveSessionForPersistKey` (pty-manager.ts:2583). If the SSH node is mounted on the desktop at that moment, the answer is "exists", so `fresh=false`. The phone then gets a blank local shell with no resume offer and no indication that anything is wrong. +> - **The orphan session sticks.** After the first open, the orphan `nt-` stays on the local `node-terminal` socket. Later opens attach to it as a warm session. `listNodetermSessions` also lists it, so the phone starts reporting the SSH node as running. +> - **More entry points than line 141.** The same unguarded open exists at SessionsTab.kt:148, BoardTab.kt:172 and InboxTab.kt:81. "End session" over the relay (SessionsTab.kt:219-220) also attaches first, which creates the orphan before destroying it. Over SSH, `sendKeys` quick answers for these nodes hit a local session that does not exist, so tmux exits 1 and the app shows an error. +> +> **Better fix — two layers:** +> 1. **Phone:** block open, resume and key-based quick actions for nodes whose project has an `sshTarget`, and say why ("runs on user@host — open it on the computer"). On the SSH transport, send approvals for those nodes to the relay verb when a relay connection exists, otherwise show "open on computer". +> 2. **Desktop:** in `handleAttach`, refuse `pty.attach` for a node where `workspaceStore.sshProjectIdForNode(nodeId)` is set, instead of letting `attachDetached` create a local session. This applies the existing "a remote node is never spawned locally" invariant to the relay attach path as well. It also protects the iOS relay path, which this repo cannot see. + +## A10 + +**Debug APKs from CI change signature from run to run; README offers them as the install route, and updating means uninstalling, which wipes pairings** + +- Severity: **medium** (BLOCKS first release); claimed by auditor: medium; effort: small; area: ci-docs; kind: risk +- Location: `README.md:228` + +**Evidence** + +README.md:227-228 says 'Android — build it from android/ ...; CI also attaches a debug APK to every run of the Android workflow.' android/README.md:37-38 says the same. The app job uploads `android/app/build/outputs/apk/debug/*.apk` (android.yml:77-80). app/build.gradle.kts has no `signingConfigs`, and no debug keystore is committed. android/.gitignore has a `!debug.keystore` exception, but no such file exists and nothing would use it. So each APK is signed with whatever ~/.android/debug.keystore the ephemeral runner has; AGP generates a fresh one when none exists, and runner images are rebuilt regularly. Installing a later run's APK over an earlier one then fails with a signature mismatch (INSTALL_FAILED_UPDATE_INCOMPATIBLE). The only way forward is uninstall. The manifest sets `android:allowBackup="false"` (AndroidManifest.xml:13), so uninstalling irrecoverably drops every paired computer and the Keystore-held relay/SSH identities. Workflow artifacts also require a GitHub login and expire after the default retention period. + +**Proposed fix** + +Commit a debug keystore and wire `signingConfigs.getByName("debug").storeFile` to it (the .gitignore exception suggests this was intended), or publish consistently signed APKs as release assets. Until then, drop the 'CI attaches a debug APK' install advice or warn that updates need an uninstall and re-pair. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause and citations are right. One refinement: the per-run key change comes mainly from each GitHub-hosted job running on a fresh VM with an empty `~/.android` (nothing in android.yml caches or injects a keystore), not from image rebuilds. +> +> The first proposed fix is unsafe as written. Committing `debug.keystore` to this public repo, which the `!debug.keystore` .gitignore exception invites, makes the signing key public. Anyone could then build an APK that installs as an in-place update over users' installs. An update keeps the app UID, so that APK inherits the app's data dir and its AndroidKeyStore key. It could then decrypt the SecureStore secrets, including the SSH private key that paired computers trust in authorized_keys and the relay identity. +> +> Safer options: +> - Sign CI builds with a key held in GitHub Actions secrets (a `signingConfig` fed from env vars, used only in CI), and publish those APKs as release assets. +> - Or, until then, reword README.md:227-228 and android/README.md:37-38. Say CI artifacts are for testing only, that each one needs an uninstall and fresh pairing, and that a locally built APK is the route to keep for updates. A local build is stable because it uses the developer's own `~/.android/debug.keystore`. +> +> Also drop the `!debug.keystore` exception, so nobody commits a public signing key by accident. + +## A11 + +**Tapping an Inbox notification while the app is in the background does not open that computer** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: build; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt:67` +- Note: Same issue as A19. + +**Evidence** + +The notification's PendingIntent carries `putExtra(MainActivity.EXTRA_HOST_ID, host.id)` with `FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP` (InboxNotifier.kt l.97-104). MainActivity is `android:launchMode="singleTask"` (AndroidManifest.xml l.24). So when the activity already exists (the common case: app backgrounded with Home), Android delivers the intent to `onNewIntent`, not `onCreate`. `onNewIntent` only does `super.onNewIntent(intent); takePairLink(intent)` (l.67-70), and takePairLink ignores anything that is not a `nodeterm://pair` VIEW intent. EXTRA_HOST_ID is only read in onCreate (`val openHost = intent?.getStringExtra(EXTRA_HOST_ID)`, l.83), and it is consumed inside `remember { Navigator(...) }`, which never re-runs. + +**Proposed fix** + +In onNewIntent, read EXTRA_HOST_ID into a Compose state, as incomingPairCode already is, and push `Route.Host(hostId, tab = 2)` from a LaunchedEffect keyed on it. Call setIntent(intent) as well. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The diagnosis and line numbers are correct, but the proposed fix is incomplete. +> +> HostScreen.kt:62 creates the tab as `var tab by rememberSaveable { mutableIntStateOf(initialTab) }`, keyed on neither `hostId` nor `initialTab`. AppContent (MainActivity.kt:112-118) renders every `Route.Host` from the same `when` branch. So if the user is already on a Host screen when the notification arrives, pushing `Route.Host(hostId, tab = 2)` recomposes the same HostScreen slot and keeps the old tab value. The Inbox tab would not open. +> +> The fix therefore needs a second part. Either: +> - key the state, e.g. `rememberSaveable(hostId, initialTab) { mutableIntStateOf(initialTab) }`, or +> - wrap each destination in `key(...)`, or +> - rebuild the stack with `nav.replaceAll(Route.Hosts); nav.push(Route.Host(id, 2))`. +> +> The rest of the proposed fix is right: store the extra in a Compose state from `onNewIntent`, call `setIntent(intent)`, and check `graph.hosts.get(id) != null` the same way `onCreate` does. +> +> Minor: `FLAG_ACTIVITY_CLEAR_TOP` has no effect on a singleTask root activity. It is harmless but does not help here. + +## A12 + +**Relay sendKeys (question answers, legacy approvals) writes into a pty that does not exist yet and then kills it immediately, so the keystroke can be lost while the UI reports success** + +- Severity: **medium**; claimed by auditor: medium; effort: medium; area: protocol; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt:266` + +**Evidence** + +Android: `val stream = attach(nodeId, 80, 24, sink); try { stream.write(keys) } finally { stream.detach() }`. The Input frame goes out as soon as the attach RESPONSE arrives, and `pty.kill` follows immediately. Desktop host-service.ts: the response goes first (`socket.respond(req.id, true, { streamId, fresh: !existed })`, line 423). Only after an async `captureSnapshot` does it set `stream.sessionId = pty.attachDetached(...)` (line 432), which spawns a brand-new tmux client (relay ptys are never co-attached: pty-manager.ts:3446-3450, 'Detached (relay-served) ptys are deliberately NOT indexed'). An Input that arrives first hits `pty.write(getClientId(), '', data)`. PtyManager.write (pty-manager.ts:3909-3911) finds no session for '' and falls through to `backgroundWriteBySessionId('')`, which matches nothing, so the input is dropped. If the client was spawned in time, the `pty.kill` processed right after it calls releaseClient → `releasePty` → `proc.destroy()` on a tmux client that has only just been spawned, before it is known to have read and forwarded the byte. The interop fixture's fake `write` accepts any sessionId, so this is not covered by tests. The throwaway 80x24 client also becomes tmux's latest client (the generated conf sets no window-size), so the desktop pane briefly resizes as the key lands. + +**Proposed fix** + +Don't write until the stream is live: wait for the first OP.Output after SnapshotEnd, which proves attachDetached ran and the tmux client painted. Then write, and delay the detach briefly (or until the pane echoes). Attach at the phone's normal size, not 80x24. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is right on substance. Corrections and additions: +> +> (a) The kill-after-write loss is worse than "before it is known to have read and forwarded the byte". In node-pty 1.1.0, `write` is an async threadpool `fs.write`, while `destroy()` closes the master straight away (unixTerminal.js:215-225, 296-341). So the byte may never reach the pty at all. Closing the master also hangs up the slave, which likely discards input tmux has not read yet. +> +> (b) The Esc Deny fallback (QuickActions.kt:37) is lost almost every time, not just sometimes. tmux holds a lone ESC for `escape-time 10` ms (pty-manager.ts:299), and the client is killed well inside that window. +> +> (c) In the pre-attach race, `attachDetached` never runs, because `handleKill` drops the stream first and the capture continuation returns at host-service.ts:428. It is a clean drop, not a late write. +> +> Better fix: add a desktop relay verb such as `node.sendKeys {nodeId, keys}`, backed by PtyManager's existing background write / tmux send-keys path. That is the same deterministic route the SSH transport uses, and it needs no throwaway tmux client. Gate it by capability ("not served" means open the session). The PR already adds desktop verbs (`approvals.answer`, `inbox.ack`), so this fits. +> +> If you stay client-only instead: +> - attach at the phone's real size; +> - wait for the first OP.Output after SnapshotEnd (proof that attachDetached ran and the tmux client is painting) before writing; +> - keep the stream open for well over escape-time, or until the pane echoes, before sending `pty.kill`. +> +> Either way, `sendKeys` should report failure instead of Unit so that QuickActions does not return SENT unconditionally. + +## A13 + +**Registering a phone-started node while the phone is attached lets the desktop's own client attach with `-D`, which detaches the phone; Android then reports the session as ended** + +- Severity: **medium**; claimed by auditor: medium; effort: medium; area: protocol; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:238` + +**Evidence** + +afterAttach types the launch line and then calls `conn.registerNode(...)` while its relay stream is still attached. Desktop side: `appendRemoteNodeNow` broadcasts `IPC.workspaceExternalChange` (workspace-store.ts:1629). The active project reloads silently, the new TerminalNode mounts and calls create(). create() cannot co-attach to the relay's session, because relay ptys are not in `byPersistKey` (pty-manager.ts:3446-3450). So it spawns a painter with `tmuxAttachFlags(false)` = `['-A', '-D']` (pty-manager.ts:751). `-D` detaches every other client, including the relay-served one. That client exits 0, host `onExit` sends `OP.Error {exitCode:0}`, and Stream.accept calls `sink.onExit(0)`. TerminalController turns that into `TermState.Ended("The session ended (exit 0).")`. + +**Proposed fix** + +Treat an exit that comes back while the tmux session still exists (re-check isLive / has-session) as a detach, and re-attach automatically instead of showing Ended. Separately, ask the desktop side to co-attach relay viewers instead of `-D`-kicking them. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The mechanism is right, but the impact is overstated in three ways: +> +> 1. It is recoverable with one tap. The Ended screen has a "Reattach" button (TerminalScreen.kt:89-95) that calls `controller.attach()`. Once the desktop client is attached, that re-attach co-attaches without `-D` and works. The real harm is a false "The session ended (exit 0)." message and a lost view, not a lost session. Medium is borderline; medium-low fits better. +> +> 2. The immediate kick needs the target project to be the ACTIVE tab on the desktop, not merely open. A background project only gets `replaceProject()`, and its node mounts later on a project switch. That switch then kicks the phone if it is still attached. +> +> 3. Line refs: +> - the no-index code is pty-manager.ts:3418 (the `indexKey` line) plus the comment and `set` at 3446-3449; +> - the renderer spawn with undefined sinks is in `spawnNew` around line 2290. +> +> Better fix: fix the root cause on the desktop, which also covers iOS. When PtyManager spawns a renderer client for a persistKey that has a live detached (relay) session, it should attach with `-A` and no `-D`. Either track detached sessions by persistKey, or have the renderer spawn check for a live relay pty, as the comment at pty-manager.ts:740-748 already intends. This does not cover the SSH-transport phone's client, which is not in this process's session table, so the Android-side guard below is still needed for that case. +> +> On Android, as a fallback, treat an exit code of 0 as a possible detach rather than an end. If the node is still in the next snapshot or listing, or a has-session check succeeds, re-attach once automatically. Otherwise show a "Detached — another viewer took over" state instead of "ended". + + +## Direct SSH coattachment follow-up (A13, 2026-10-04) + +Fixed in `ed2965be` after the beta-14 APK checkpoint. A current Desktop app attaches beside +external SSH/relay clients, then replaces only a live app painter attested by an exact PID/birth +receipt in the same private userData profile and tmux socket/session. Both old and incoming client +identities are rechecked in the tmux command queue before an exact-client detach. A failed or +departed new attach cannot evict the old viewer. Unknown, legacy unmarked and other-profile clients +survive; missing process attestation preserves viewers. Existing per-manager joins and grid policy +remain. Exit/quit releases the exact receipt; only proven stale receipts are pruned within bounds. + +Private controls/restored pass 122 focused tests and full TypeScript checking. Twenty-four distinct +isolated mutations fail assertions; three critical variants are additionally caught against the +actual tmux server. Seven real private-socket tmux control-client methods pass without skips on +tmux 3.7c: both phone/app attachment orders, unequal native pane grids, remounts, foreign/legacy +clients and stale-client races. Control-client height formatting is empty on this host, so grid +evidence reads the native pane and checks phone-only restoration. + +An additional Linux smoke at source `32d412b6` passes eight checks using cached Electron 42.11.3, +Node 24.19.0, actual node-pty and tmux 3.7c. It exercises the production `PtyManager` and painter +tracker in a private home/tmp/run/PID/network namespace: external/app attachment in both orders, +same-manager joins, selective app remount takeover with exact shell output retained, and normal +quit receipt cleanup while the external client and shell survive. Observed native pane grids +return to 56×48 after the 100×40 app replacement leaves. The in-memory `CorePlatform` adapter is +an explicit IPC boundary; no actual renderer, SSH handshake, relay, Pixel, macOS or Windows runtime +pass is claimed. The cached native binding, Electron binary, all 96 bundled source inputs and +external node-pty wrappers are hash-bound and unchanged across the final run. + +The private painter receipts are internal app state, outside the phone's named workspace/status/ +project files; no host-service verb, payload, mirror or client wire shape changes. Android keeps +its exit-zero reattach fallback for older hosts. The same host policy preserves iOS SSH clients; +@eneskirca should verify physical coattachment. Prepared beta 14/code 15's Android client remains +unchanged and uninstalled; host-side verification uses the later source. The device ledger stays +10 Pass / 22 Partial / 32 Pending, and phone testing remains paused. + +## A14 + +**New sessions in cwd-less (inline) projects are never registered: the desktop refuses them, the refusal is ignored, and the session is orphaned** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: protocol; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt:289` + +**Evidence** + +NewSessionDialog offers `snapshot.openProjects().filter { it.sshTarget == null }`, which includes cwd-less canvases. The desktop registrar only handles folder refs: `const e = this.index?.entries.find((x) => x.id === projectId && x.cwd); if (!e?.cwd) return false` (workspace-store.ts:1607-1608), so the answer is `{registered:false}`. TerminalController.afterAttach wraps the call in `runCatching { conn.registerNode(...) }` and never reads the Boolean. host-service documents a refusal as an answer: 'the phone opened its session either way and just stays unregistered'. + +**Proposed fix** + +Only offer projects that have a cwd, and check `registered`. On false, tell the user and offer to end the session (or keep it reachable from a phone-local list). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two corrections to the impact, and one to the fix. +> +> - **Not wholly invisible on the desktop.** The canvas never shows the session, but the desktop's session-memory panel (RAM pill) lists every `nt-*` session. It labels unowned ones as orphans and offers a kill button. So the tmux session can still be found and ended from the desktop; it is not a completely invisible leak. +> - **Two lines to cite.** The offer is at `SessionsTab.kt:289` (and the FAB gate at `HostScreen.kt:71-72`). The ignored refusal is at `TerminalController.kt:236-239`. +> - **The fix has two parts.** +> 1. Filter the dialog and the FAB gate on `it.cwd != null` as well as `it.sshTarget == null`. +> 2. Read the result of `registerNode` instead of swallowing it. This is still needed after the filter, because a folder project can also be refused: its `project.json` may be unreadable or corrupt, or the account binding may be refused. On `false` or an exception, tell the user the session was not added to the project, and offer to end it or keep it reachable (for example, a phone-local list of unregistered live sessions). +> +> Registering before typing the launch line is not an option: the code comment says registering first would let the desktop cold-mount the node and launch the agent a second time. + +## A15 + +**The cold-attach resume offer drops the node's managed Claude account (and on the relay, its cwd), so the resume fails with 'No conversation found'** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: protocol; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:253` + +**Evidence** + +On `s.fresh` the phone offers `Launch.resumeCommand(agent, sid)`, which is bare, e.g. `claude --resume `. It ignores `node.accountId` (parsed in ProjectsParser.parseNode) and settings.claudeAccounts[].dir. The desktop's cold restore runs inside a pty spawned with the node's account env (`CLAUDE_CONFIG_DIR` via tmux `-e`, pty-manager accountTmuxEnvArgs) and the node's cwd, and wraps the command in `withPermissionMode`. A relay cold attach creates the session with only `{cols, rows}`: no account env and cwd = $HOME (pty-manager.ts:2254-2259). A managed account's transcript lives under `/projects/…`, so running under the default `~/.claude` finds nothing. + +**Proposed fix** + +Build the resume line the way launchCommand builds a first launch: `cd '' && CLAUDE_CONFIG_DIR='' claude --resume `, plus the permission-mode flag as the desktop's cold restore would add it. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Mostly accurate. Four refinements: +> +> 1. **Impact on the relay is probably wider than managed accounts.** Relay cold attach also drops the cwd, and Claude finds a transcript at `/projects//.jsonl` (the CLAUDE.md measurement). So `claude --resume ` typed into a relay-recreated pane in $HOME likely fails for system-account Claude nodes too, not only managed ones. That part is inferred from the CLAUDE.md note, not measured here. On direct SSH the cwd is already supplied through `new-session -c`, so there only the account (and permission mode) is missing. +> +> 2. **The fix belongs in `Launch`.** Add a resume builder that shares `launchCommand`'s prefix logic: `cd '' &&` gated on `SAFE_DIR`, then `CLAUDE_CONFIG_DIR=''` only for CLAUDE and only when `node.accountId` matches a `settings.claudeAccounts` entry. Call it from TerminalController.kt:253 with `node?.accountId` and `node?.cwd ?: project cwd`. +> - The cwd must be absolute. Portable `./` cwds are only resolved on the SSH listing path, in `SshHostConnection`. +> - Add the same Claude-only permission-mode flag. +> +> 3. **It remains a partial fix.** It cannot reproduce the desktop's `AUTH_ENV_STRIP` removal of an inherited `ANTHROPIC_API_KEY`. The relay-created pane also still gets no hook env, so status reporting from that pane stays dark. +> +> 4. **Codex is not fixable from the phone.** Managed Codex accounts (`CODEX_HOME`) are dropped the same way, but the mirror does not advertise Codex account homes. + +## A16 + +**The phone's launch ignores the project's own permission mode (and default account), so a project the user set to a stricter mode starts in the global mode** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: protocol; kind: gap +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt:59` + +**Evidence** + +Android: `val mode = settings?.claudePermissionMode?.takeIf { it in PERMISSION_MODES } ?: "manual"`, i.e. the global mirror value only. The desktop resolves `resolvePermissionMode(project, settings)`, 'the project override, else the global setting' (shared/agents/config.ts:754). `defaultPermissionMode` is carried through project.json into the assembled workspace that `projects.list` serves (workspace-files.ts:377/540), and so is the machine-local `defaultAccountId`. ProjectsParser.parseProject reads neither, and NewSessionDialog defaults the account to System. + +**Proposed fix** + +Parse `defaultPermissionMode` and `defaultAccountId` in ProjectInfo, and resolve project → global exactly as resolvePermissionMode does (keeping the claude-only autoSupported degrade). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is correct. Three points to make it more precise: +> 1. The loosening to `auto` happens only when the mirror's `autoSupported` is true. Otherwise the global `auto` degrades to the bare command, which is manual, so a stricter project is not loosened in that case. It does still happen when the global mode is `acceptEdits` or `bypassPermissions`, because those modes are emitted without any version gate. The reverse direction also exists: a project override that is looser than the global mode is ignored as well. That is a parity gap, not a security issue. +> 2. More file:line locations for the fix: +> - SessionsTab.kt:293 (accountId initialised to null) +> - SessionsTab.kt:330 (launchCommand is not given the project) +> - ProjectsParser.kt:52-61 (neither field is parsed) +> - Models.kt:31-42 (ProjectInfo has neither field) +> 3. Fix: +> - Parse `defaultPermissionMode` and `defaultAccountId` into ProjectInfo. +> - Pass the project, or the resolved mode, into `launchCommand`, and resolve project → global → default using the same `isPermissionMode` re-validation the desktop uses. Keep the claude-only `autoSupported` degrade after resolving. +> - Preselect `project.defaultAccountId` in NewSessionDialog, but only when it matches an entry in the mirror's `claudeAccounts`. That mirrors how the desktop validates an account id before stamping it on a node; a stale id should fall back to System. + +## A17 + +**The background inbox worker dials the relay for unapproved phones, putting the desktop's SAS approval dialog up every 15 minutes with no code on the phone to compare it against** + +- Severity: **medium**; claimed by auditor: low; effort: small; area: protocol; kind: risk +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:124` +- Note: Same issue as A05. + +**Evidence** + +InboxWorker runs `withTimeoutOrNull(45_000) { session.refreshNow() }` for every paired host. refreshNow calls ensureConnected, which calls RelayConnector.connect and then polls `projects.list` while the host answers 'Awaiting host approval.'. On the desktop, standing-host.ts onPeerReady sends `remoteHostPeerPending` for any unpinned key, and the consent record outlives the socket by 120 s (#819). A reject pins nothing, so the next run prompts again. + +**Proposed fix** + +Skip relay connects from the worker until this phone's key has been approved once in the foreground (record that locally after the first successful relay listing). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The cited line (InboxNotifier.kt:124) and the mechanism are right. Two refinements: +> +> 1. The trigger is broader than "never approved or rejected" phones. On a macOS/Linux computer, every phone that paired on the LAN and has only ever connected over SSH still has an unpinned box key, because pairing-service does not pin it; only standing-host's SAS approval does. That includes phones that picked up a relay block through the QR or through adoptRelayIfAdvertised (ConnectionManager.kt:165-187). The first time such a phone is off the LAN, the SSH attempt fails (4 s AUTO timeout) and the background worker dials the relay. The desktop then gets an unexpected SAS dialog whose code nobody can see. For this app that is the common first-off-LAN path, not an edge case. +> +> 2. The fix has to be "don't dial the relay at all from the worker until approved". An early abort would not help: the desktop raises the dialog in onPeerReady as soon as the handshake completes, before any request, and keeps it for 120 s after close. Concretely: +> - Persist a per-host `relayApproved` flag in HostStore when RelayConnector.connect returns (a successful projects.list means the key is pinned). +> - Skip the relay leg in background runs until that flag is set, e.g. by passing an `allowRelayApproval=false` / background mode into connectLocked. +> - If the host later answers AWAITING_APPROVAL_MESSAGE (the pin was revoked), clear the flag. +> +> Side note found while verifying: connectLocked's `catch (e: Exception)` at ConnectionManager.kt:123 also catches the worker's timeout CancellationException (it is an Exception subclass) and rewrites it as a HostException. It is harmless here, because withTimeoutOrNull still returns null, but cancellation is not propagated cleanly. + +## A18 + +**No lifecycle handling: a backgrounded app keeps the 8 s poll and its relay terminal stream alive indefinitely** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt:58` + +**Evidence** + +- TerminalScreen.kt:57-63 and HostScreen.kt:65-68 call `session.startWatching()` (and the terminal attaches) inside DisposableEffect. DisposableEffect disposes only when the composable leaves composition, not on ON_STOP. +- Nothing under android/app references Lifecycle, onPause/onResume or WebView.onPause (grep). +- ConnectionManager.kt:211-222: pollJob runs on graph.scope, looping `refreshNow(); delay(POLL_MS)` while watchers > 0. Its own KDoc calls this "the iOS foreground cadence". +- Desktop side: host-service.ts:397-402 calls `remoteViewer?.attached(nodeId)` for the relay stream's whole lifetime. index.ts:3970-3978 says this set makes "Eco's isNodeWatched stop hibernating a session someone is watching from a phone". On the session host a relay sink is bounding (pty-manager.ts:3701 `const bounding = session.sizes.has(null) && !session.sinkAdapts`). +- InboxNotifier.kt:127 never disconnects a session whose isWatched is still true. + +**Proposed fix** + +Tie watching to the Activity lifecycle: use a LifecycleEventObserver on LocalLifecycleOwner, or repeatOnLifecycle(STARTED). On ON_STOP call stopWatching, detach the terminal stream and call webView.onPause(). On ON_START watch again and reattach. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> 1. **"Indefinitely / until the OS kills the process" is overstated.** +> - When the screen is off and no wakelock is held, the CPU sleeps. Doze and app-standby later cut network access. +> - On Android 14+ (and some 12/13 builds), the cached-app freezer stops a process once it drops to the cached state. +> - The accurate claim: the loop keeps running for as long as the process is not frozen or asleep. That covers the whole Home or app-switch window, and screen-lock until the device sleeps. While the process is alive, the phone's keepalives stop the desktop from dropping the stream, so an agent streaming output keeps waking the radio. +> - The desktop effects (Eco shielding, the session-host size ceiling) are real, and they apply specifically to the relay stream: `remoteViewer` exists only in `host-service`. On SSH, a tmux client simply stays attached. +> 2. **The terminal attach is not inside the `DisposableEffect`.** It is triggered by the JS `Bridge.onResize` in `TerminalController.kt`; only `startWatching` and `dispose` are in the effect. The conclusion is unchanged. +> 3. **Better fix, using the dependency already declared.** +> - `lifecycle-runtime-compose` 2.9.1 provides `LifecycleStartEffect`. Use `LifecycleStartEffect(hostId) { session.startWatching(); onStopOrDispose { session.stopWatching() } }` in `HostScreen` and `TerminalScreen`. +> - In `TerminalController`, add a stop/start pair. On ON_STOP: detach the stream and null `stream`, keeping the WebView, then call `webView.onPause()`. On ON_START: call `webView.onResume()` and re-`attach()`. +> - Optionally, `InboxWorker` could also disconnect when `ProcessLifecycleOwner` reports the app is not STARTED. + +## A19 + +**Tapping an inbox notification while the activity is alive ignores the target computer** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt:67` +- Note: Same issue as A11. + +**Evidence** + +InboxNotifier.kt:97-104 builds the PendingIntent as `Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_HOST_ID, host.id).addFlags(FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TOP)`. The activity is `android:launchMode="singleTask"` (AndroidManifest.xml:24), so a live instance receives the intent in onNewIntent. MainActivity.kt:67-70 is `override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); takePairLink(intent) }`, and takePairLink (:60-65) only reads a nodeterm://pair data URI. EXTRA_HOST_ID is read once, in onCreate (:83 `val openHost = intent?.getStringExtra(EXTRA_HOST_ID)`), into the initial Navigator (:89). + +**Proposed fix** + +In onNewIntent, call setIntent(intent), read EXTRA_HOST_ID into a mutableState (as incomingPairCode does), and have a LaunchedEffect push Route.Host(hostId, tab = 2). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line citations and root cause are correct. The proposed fix has one gap. HostScreen keeps its tab in `var tab by rememberSaveable { mutableIntStateOf(initialTab) }` (HostScreen.kt:62), and AppContent renders every Route.Host from the same `when` branch (MainActivity.kt:116). So pushing Route.Host(hostId, tab = 2) while the current route is already a Route.Host keeps the existing saved tab and ignores `initialTab`. That covers two cases: the user is on that computer's Sessions or Board tab, or they are on a different computer's host screen, which also reuses the slot and its state. The fix needs three parts: +> 1. In `onNewIntent`, read EXTRA_HOST_ID into a mutableState, as `incomingPairCode` does (and optionally call `setIntent`). +> 2. In a LaunchedEffect, validate the id with `graph.hosts.get(id)` and push or replace to Route.Host(id, 2). +> 3. Make HostScreen honour the new route: wrap the branch in `key(r) { … }` or use `rememberSaveable(hostId, initialTab)`. Otherwise the navigation does nothing when a host screen is already showing. + +## A20 + +**Cancelling an in-flight connect leaks the SSH connection and records the cancellation as a connection failure** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt:94` + +**Evidence** + +- ConnectionManager.kt:79-96: `val ssh = withContext(Dispatchers.IO) { SshHostConnection.connect(...) }` ... `} catch (e: Exception) { errors += "On your network: ..." }`. CancellationException is an Exception, so this swallows it. withContext's prompt-cancellation guarantee then discards the SshHostConnection that the blocking connect returned, and nothing closes it (keepalive is enabled at SshHostConnection.kt:353). +- The catch at :123 swallows cancellation on the relay leg too, and :132-134 then set `ConnState.Failed("...was cancelled...")`. +- The cancellation comes from stopWatching (:225-231, `pollJob?.cancel()` when watchers reaches 0). +- watchers reaches 0 on every Host→Terminal push as well: Compose dispatches onForgotten before onRemembered (checked in the runtime-desktop 1.8.2 RememberEventDispatcher.dispatchRememberObservers bytecode). So HostScreen.kt:67 `stopWatching()` runs before TerminalScreen.kt:58 `startWatching()`. + +**Proposed fix** + +Rethrow CancellationException in connectLocked before the generic catches. Make the SSH dial close its result when the caller was cancelled, for example by running it as an async in graph.scope and awaiting it, closing the connection if the awaiting coroutine was cancelled. Also consider keeping one watch token across the Host→Terminal hand-off so the poll job is not cancelled on every navigation. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is correct on mechanism and cause. Refinements: +> +> (a) The leak site is ConnectionManager.kt:79-85: the discarded result, with `adopt` never reached. :94 is where the cancellation is swallowed. The relay leg swallows it again at :123, and :132-134 set `ConnState.Failed` and throw. +> +> (b) The wrong "offline" state is not always brief. When the user backs out of HostScreen, nothing else is watching, so no new pollJob replaces it. `ConnState.Failed` stays until the host is reopened: HostsScreen.kt:168/175 shows " — offline" in the attention colour, and `_lastError` holds "StandaloneCoroutine was cancelled". It is brief only on the Host→Terminal hand-off, where TerminalScreen's new pollJob reconnects. That reconnect is a second dial, while the first connection stays leaked. +> +> (c) There are more cancellation sources than the poll job. BoardTab.kt:98-99 and InboxTab.kt:83-84 call `session.ensureConnected()` inside a `rememberCoroutineScope()` launch. That scope is cancelled when the tab or screen leaves composition, which triggers the same leak if that coroutine holds the mutex during the dial. +> +> (d) Fix. Rethrow CancellationException before both generic catches in connectLocked. Also close a dial that finished after cancellation. Two ways: +> - Record the result inside the IO block (`.also { dialed = it }`) and, in a `catch (e: CancellationException) { dialed?.close(); throw e }`, close it. withContext does not resume the caller until the blocking block has returned, so `dialed` is set by then. +> - Run the dial as `graph.scope.async(Dispatchers.IO)`. If the await is cancelled, close the result in `invokeOnCompletion`. +> +> Keeping one watch token across the Host→Terminal hand-off, or delaying the cancel in `stopWatching`, would avoid needlessly cancelling the pollJob. It does not fix the back-out case or the rememberCoroutineScope callers, so the close-on-cancel change is the real fix. +> +> (e) Severity medium is reasonable. The leak needs the cancel to land during a dial that then succeeds. Each occurrence leaves one live SSH session to the desktop for the life of the process. + +## A21 + +**A denied POST_NOTIFICATIONS is never re-requested, and the Settings switch still reads On** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt:89` + +**Evidence** + +- MainActivity.kt:72-82 requests POST_NOTIFICATIONS once, in onCreate, with an empty callback commented `/* the Settings switch reflects it */`. +- SettingsScreen.kt:51 is `var notify by remember { mutableStateOf(graph.hosts.notificationsEnabled) }`. The switch at :89-93 only writes the pref and calls InboxNotifier.schedule; it never checks or requests the runtime permission. +- InboxNotifier.kt:74-76 returns 0 silently when the permission is not granted. Meanwhile the worker still dials every paired computer every 15 minutes. + +**Proposed fix** + +Show the switch as on only when `notificationsEnabled && permission granted`. When the user turns it on, or opens Settings while permission is denied, launch the permission request. If it is permanently denied, deep-link to Settings.ACTION_APP_NOTIFICATION_SETTINGS. Don't run the worker without the permission. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The permission is not requested "once" or "never re-requested". MainActivity.kt:78-81 runs in every onCreate: every cold start, and every activity recreation such as rotation. It fires whenever the pref is on and the permission is not granted. What actually happens: +> 1. On Android 13+ the system stops showing the dialog after the user denies it twice. From then on `launch()` fails silently, and the app has no path to recover. +> 2. Flipping the Settings switch off and on never requests the permission, and nothing opens the system notification settings. +> 3. The launch-time prompt can repeat on every rotation or cold start until that system cutoff. +> +> The same "switch says On, nothing arrives" state also happens on API 26-32 when the user blocks notifications or a channel in system settings. There `nm.notify` is a silent no-op and the switch still reads On, so the fix should check `NotificationManagerCompat.areNotificationsEnabled()` (and the channel importance), not only the runtime permission. +> +> Side effect: `announce` returns before `markSeen` when the permission is missing. After a later grant, every unresolved event from the last 6 hours is posted at once (up to 5 per host, via `fresh.takeLast(5)`). +> +> Better fix: +> - Derive the displayed state as `pref && areNotificationsEnabled()`, re-checked on resume. +> - Request the permission from the switch, and after pairing the first computer rather than in onCreate. +> - Fall back to `Settings.ACTION_APP_NOTIFICATION_SETTINGS` when `shouldShowRequestPermissionRationale` is false after a denial. +> - Skip or cancel the WorkManager job while notifications are disabled. + +## A22 + +**The Navigator back stack is not saved across activity recreation, and the original launch intent is re-applied** + +- Severity: **medium**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/MainActivity.kt:87` + +**Evidence** + +- :87 `val nav = remember { Navigator(Route.Hosts)... }` uses plain remember, not rememberSaveable, and Route has no Saver. +- AndroidManifest.xml:26 configChanges covers orientation/screenSize/uiMode but not density, fontScale, locale or layoutDirection, so those changes (and process death) recreate the activity. +- onCreate reads `intent` again each time (:83-84), and onNewIntent never calls setIntent. + +**Proposed fix** + +Save the route stack with rememberSaveable and a listSaver. Only apply the intent's extras/data when savedInstanceState == null, or clear them once consumed. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is accurate. Two refinements: +> +> (a) Re-opening the Pair screen does not re-submit the used token. PairScreen only pre-fills the parsed payload, and the user must tap "Pair" again, which would then fail on the server. So the impact is a stale screen and a confusing error, not a duplicate pairing. +> +> (b) A related, more impactful bug sits on the same lines. With launchMode="singleTask" (Manifest:24), a notification tap while the activity already exists is delivered to onNewIntent. That PendingIntent is built with NEW_TASK|CLEAR_TOP in InboxNotifier.kt:99-103. onNewIntent (MainActivity.kt:67-70) only calls takePairLink, which returns early because the intent has no data, so EXTRA_HOST_ID is ignored. Tapping a "Needs you" or "Completed" notification while the app is alive in the background just brings it forward on whatever screen it was on, without opening that host's inbox. Only a cold start honours the extra (:83, :89). +> +> Fix for both: +> - Save the stack with rememberSaveable and a listSaver, or make Route @Parcelize. +> - In onCreate, consume the intent extras/data only when savedInstanceState == null, or clear them after use (setIntent(Intent(intent).setData(null).removeExtra(...))). +> - In onNewIntent, call setIntent(intent) and route EXTRA_HOST_ID into the same state-holder mechanism as incomingPairCode, so the Compose side pushes Route.Host(hostId, tab = 2). + +## A23 + +**Background inbox worker opens unapproved relay connections, raising desktop SAS approval dialogs the phone never shows** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: security; kind: risk +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:124` +- Note: Same issue as A05. + +**Evidence** + +InboxWorker.doWork calls `withTimeoutOrNull(45_000) { session.refreshNow() }` for every paired host (line 124). That goes to ensureConnected → connectLocked, which in Auto falls to `RelayConnector.connect(...)` (ConnectionManager.kt:99-122). There is no 'already approved' gate. For an unpinned phone key, the standing host raises the approval prompt on every connect: standing-host.ts:250-261 `if (pub && isPinned(store, pub)) { s.approve() ... }` else `send(IPC.remoteHostPeerPending, { sas: s.sas(), ... })`, and the consent record lives 120 s after the socket closes (#819). On the phone, the AwaitingApproval SAS only lands in `_state` (ConnectionManager.kt:114), which nothing renders while no screen is open. This is the common first relay use: a Mac user who paired and used SSH at home first reaches the relay when the phone leaves the LAN, typically from this worker. + +**Proposed fix** + +Track a per-host 'relay-approved' flag, set after the first successful RelayConnector.connect in the foreground. In background refreshes, refuse to proceed on AWAITING_APPROVAL_MESSAGE: close immediately, and instead post a notification such as 'Open nodeterm to approve this phone on '. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two corrections. +> +> **1. The 120 s window is timed from the prompt, not from the socket close.** `PHONE_APPROVAL_TTL_MS` starts in `approvals.add()` (phone-approval.ts:38-45), which runs when the prompt is raised. It is not 120 s "after the socket closes": the desktop dialog lives about 120 s from when it appears, and the phone's socket closes at about 45 s. +> +> **2. The proposed fix does not work.** "Refuse to proceed on AWAITING_APPROVAL_MESSAGE: close immediately" is too late. The desktop raises the dialog in `onPeerReady`, as soon as the E2EE handshake completes (standing-host.ts:233-261), before the phone sends any request. So the phone first sees AWAITING_APPROVAL_MESSAGE only after the prompt is already on screen, and closing then still leaves a live dialog for 120 s. +> +> **A fix that works:** +> - **Skip the relay leg in the background.** Persist a per-host "relay approved" flag and set it after the first successful relay `listProjects` (RelayConnector.kt:72-74). In background (worker) refreshes, do not dial the relay at all while the flag is unset. Post a notification instead, such as "Open nodeterm to approve this phone on ". +> - **Show the code on the phone at any time.** The SAS is derived from the static ECDH `baseKey` (relay-socket.ts:168-170, 236, 602-603 and e2ee.ts `sasFromSharedKey`), so it is stable for a given phone key and host key. The app can compute it from `graph.boxKeys` plus `host.relayHostKeyB64` without connecting. It could then show the code in that notification or on the host screen, so a desktop prompt can always be verified. Today the phone only shows the code during a foreground relay connect, and while the phone is on the LAN the foreground connect goes over SSH and never shows it. + +## A24 + +**SecureStore treats ANY decrypt error as 'absent', so getOrCreate32 permanently overwrites the phone's identity** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: security; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/data/SecureStore.kt:60` + +**Evidence** + +getBytes wraps key lookup, `cipher.init` and `doFinal` in `catch (_: Exception) { null }` (lines 55-64). That catches KeyStoreException, ProviderException, IllegalStateException and other transient keystore failures, not only 'key wiped / wrong device'. getOrCreate32 (lines 77-82) then writes fresh random bytes over the stored value: `getBytes(name)?.takeIf { it.size == 32 }?.let { return it }; val fresh = ...; putBytes(name, fresh)`. AppGraph uses it for both BOX_SECRET and SSH_SEED (NodetermApp.kt:144-147). + +**Proposed fix** + +Regenerate only on positive evidence: the alias is absent (a new key was just generated), or decrypt fails with AEADBadTagException / KeyPermanentlyInvalidatedException. For any other exception, rethrow or retry and never overwrite. Consider `commit()` for the first write of the identity secrets. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> 1. Wrong location: the AppGraph uses are at NodetermApp.kt:25 and NodetermApp.kt:28, not lines 144-147 (the file has only 51 lines). +> +> 2. The impact is overstated. The desktop does not "stop accepting" the phone. A relay connect with a new box key reaches standing-host.ts onPeerReady (lines 249-262). The key is not pinned, so the desktop raises a new SAS approval dialog: `approvals.add(pub)` + `remoteHostPeerPending`, and the phone shows AwaitingApproval (ConnectionManager.kt:111-113). The relay device token is not tied to the phone's key; it is minted from deviceId, hostDeviceId, hostPublicKeyB64 and label. So the real cost is: +> - every paired computer has to re-approve the phone, which needs someone at the desk, and that is the one thing a phone user usually cannot do; +> - direct SSH fails authentication until the phone is re-paired, because authorized_keys still holds the old public key. +> In AUTO route, ConnectionManager.kt:75-95 catches the SSH failure and falls back to the relay; SSH_ONLY hosts are simply broken. The stale authorized_keys lines are harmless leftovers: the old seed has been overwritten, so nothing can use them. +> +> 3. The trigger is narrower than implied. Kotlin's `lazy` does not cache a thrown exception. So if `key` fails to initialise in both `getBytes` and the `putBytes` that follows it, `putBytes` throws out of `getOrCreate32`, the lazy `boxKeys`/`sshIdentity` throws, and the result is a failed connection with no overwrite. An overwrite needs one of two things: +> - (a) a failure specific to decryption (in `init` or `doFinal`) while encryption, a moment later in the same synchronized call, succeeds; or +> - (b) `ks.getKey(ALIAS, null)` briefly returning null. +> Case (b) is worse. Line 31 then calls `KeyGenerator.generateKey()` under the same alias, which replaces the Keystore key itself, so every stored secret becomes undecryptable, the relay tokens included. How often Android Keystore actually does either was not measured here. Without that, severity is medium at most, arguably low. +> +> 4. The fix needs adjusting. KeyPermanentlyInvalidatedException cannot happen here, because the key has no user-authentication requirement (no `setUserAuthenticationRequired` in lines 34-38). The positive evidence of loss is: +> - the alias was absent when the key was created (check `ks.containsAlias(ALIAS)` explicitly, and generate only in that case); +> - AEADBadTagException on `doFinal`; +> - a malformed blob: IllegalArgumentException from Base64, or a blob shorter than IV + tag. +> Every other exception should propagate, like the desktop's PeerKeyLockedError, and never lead to a write. `getBytes` should return a three-way result (absent / value / unreadable-now) instead of `ByteArray?`. The relay-token readers (ConnectionManager.kt:101, HostsScreen.kt:121) have the same null-on-error behaviour. For them it only produces a misleading "Remote access isn't set up" message and deletes nothing. + +## A25 + +**No real push notifications: 15-minute background polling, no notification actions, no Live-Activity equivalent, and the desktop's phone-push switches are ignored** + +- Severity: **medium**; claimed by auditor: high; effort: medium; area: parity; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:64` +- Note: Related: A73. + +**Evidence** + +iOS gets server-sent pushes that wake the app: src/core/push-notify.ts:1-8 ("Desktop → paired-phone APNs push … a paired iPhone should get a real APNs push even with the app backgrounded/killed"). Those pushes carry actions: push-notify.ts:57-58 ("The backend renders them as numbered notification actions"). They also drive Live Activities: push-notify.ts:455 ("`POST {apiBase}/v1/push/live-update`, feeding iOS Live Activities"). The user controls them per host and per kind: src/shared/types.ts:1770-1786 (`mobilePushNeedsYou`, `mobilePushDone`, `mobileLiveActivities`, `mobilePushPresenceAware` "Hold phone ALERTS while you're actively at this computer"), and push-notify.ts:92-97 describes a per-host mute (#435). Over plain SSH, iOS drops a grant at `~/.nodeterm/push-grants/.grant` (src/core/remote-push-grants.ts:1-12). Android only polls: `PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES)` (InboxNotifier.kt:64). `build()` (InboxNotifier.kt:105-116) creates a plain notification with no `addAction` for Approve/Deny/options, and its tap opens the computer (`putExtra(MainActivity.EXTRA_HOST_ID…)`, :101), not the session. `announce()` posts every unresolved kind with one global switch (`graph.hosts.notificationsEnabled`), so the desktop's Needs-you/Done toggles, presence deferral and per-host mute do not apply. + +**Proposed fix** + +Add an FCM leg to the backend's `/v1/push/notify` and `/v1/push/live-update`, sharing the existing host-identity and grant auth. Then register an FCM token and write a push grant from the phone. Until that exists: add notification actions (Approve/Deny through `QuickActions`, numbered option actions), deep-link the tap to the node's terminal, and honor the mirror's push preferences if they are published. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Downgrade to medium and restate it as three app-side gaps plus one backend dependency that is already documented: +> (a) Backend (documented at InboxNotifier.kt:31-39, android/README.md:52-57, docs/android.md:90-91): with no FCM leg in `/v1/push/notify` and `/v1/push/live-update`, the app can only poll with WorkManager every 15 minutes, and there is no Live Activity equivalent. +> (b) InboxNotifier.kt:105-114: no notification actions. Add Approve/Deny for APPROVAL and numbered options for QUESTION, handled by a BroadcastReceiver that calls the existing `QuickActions.answerApproval` / `answerQuestion`. +> (c) MainActivity.kt:67-70 and :83-90: with `launchMode="singleTask"`, a tap while the app is alive goes to `onNewIntent`. That only handles the pair link, so the notification's `EXTRA_HOST_ID` is dropped and nothing navigates. Handle it in `onNewIntent`, and add the node id so the tap can open `Route.Terminal` or the Inbox entry. +> (d) Only one global switch (HostStore.kt:83), with no per-host mute. +> Drop the claim that desktop toggles are ignored. `mobilePushNeedsYou`, `mobilePushDone` and `mobilePushPresenceAware` are not in `MirrorSettings` (agent-status-mirror.ts:173-197), so no phone can read them. They only gate the desktop's own APNs send. Android already has separate attention and done channels (InboxNotifier.kt:41-55) for per-kind control. + +> Mostly correct, with these fixes: +> +> **Severity: medium, not high.** The gap is documented (InboxNotifier.kt:31-39, android/README.md:54-57, docs/android.md:90-91), and its root cause is that the backend's `/v1/push/*` is APNs-only. That backend is outside this repo. +> +> **The deep-link claim is wrong; replace it with the real bug.** +> - The tap does go to the Inbox tab: `Route.Host(openHost, tab = 2)` (MainActivity.kt:88), and tab 2 is Inbox (HostScreen.kt:112). +> - The actual defect: MainActivity is `singleTask` (AndroidManifest.xml:24), and `onNewIntent` (MainActivity.kt:67-70) handles only pair links. +> - `EXTRA_HOST_ID` is read only in `onCreate` (MainActivity.kt:83). +> - So with the app task alive, a notification tap does not navigate at all. +> - Fix: handle `EXTRA_HOST_ID` in `onNewIntent`, e.g. through a state flow the navigator observes. Also consider adding a node id so the tap can open that session directly. +> +> **Desktop toggles: the proposed fix won't work as written.** The toggles are not published: `MirrorSettings` (src/core/agent-status-mirror.ts:173-197) has no push fields, so "honor them if published" is not possible today. +> - Doing it needs a desktop change: add the needs-you/done/presence flags to `MirrorSettings`. +> - Android already offers per-kind muting through its separate `attention`/`done` channels (InboxNotifier.kt:48-55). +> +> **Live Activities:** not an independent gap. The Android equivalent (an ongoing notification) would also need FCM or a foreground service. +> +> **Fixable in-app now:** +> - Add `addAction` Approve/Deny and numbered options in `InboxNotifier.build`, handled by a BroadcastReceiver or worker that calls the existing `QuickActions` (QuickActions.kt:23-48). Only claude approvals can be answered this way (QuickActions.kt:35-37); others return OPEN_SESSION. +> - Fix the `onNewIntent` deep link. + +## A26 + +**Current follow-up:** A107 serves direct Git on admitted local/driven folders independently of +the actions service. A108 adds selected-profile SSH Board on Desktop/Server and delivery-only +node nudges on Desktop. A manually added SSH host has no relay fallback for a missing service +capability. A111 adds host-owned managed New on current enabled local Linux/macOS tmux hosts; +physical verification remains pending. Another desktop's driven projects retain only +machine-local operations, including direct Git, rather than this profile's Board/node actions. + +**New session and board edits are unavailable on the LAN (direct-SSH) connection that Auto picks first; iOS does both over SSH** + +- Severity: **medium**; claimed by auditor: high; effort: medium; area: parity; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt:71` + +**Evidence** + +iOS registers phone-started sessions over direct SSH: src/core/project-node-append.ts:2-3 ("the phone's twin of this logic lives in nodeterm-ios ProjectNodeRegistrar and writes over direct SSH") and :23 ("the DIRECT-SSH registration path has always written it"). It also moves cards and edits labels over SSH: src/main/index.ts:3940 ("`setCardColumn` moves one card. The phone could already do this over direct SSH") and CLAUDE.md:4784 ("The iOS direct-SSH path has a Swift twin of the transform for projects on the host it dials"). Android: `val canCreate = (state as? ConnState.Connected)?.kind == TransportKind.RELAY && …` (HostScreen.kt:71), so the New-session FAB is hidden on SSH. SshHostConnection sets `HostCapabilities(boardWrites = false, git = false, nodeActions = false, registerNode = false, …)` (SshHostConnection.kt:61-63), and `registerNode`/`setCardColumn`/`editCardLabels` are `relayOnly(...)` (:254-258). BoardTab then shows "Read-only here — board edits need the relay connection." (BoardTab.kt:127-129). The `Auto` route tries SSH first and keeps it when it works (`if (route != RoutePreference.RELAY_ONLY && host.sshAvailable)`, ConnectionManager.kt:76), and the app never opens the relay for a relay-only verb even when the computer has a relay leg. + +**Proposed fix** + +When connected over SSH, lazily open the relay leg (if the computer has one) for `projects.registerNode` / `ensureBoard` / `setCardColumn` / `editCardLabels` / `node.*`. Or implement the SSH writes the way iOS does, but stream the file over stdin rather than argv so it avoids MAX_ARG_STRLEN. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Real, but the claim is overstated in four places, and I would rate it medium rather than high. +> +> 1. **Documented, deliberate decision.** docs/android.md:95-98 says: "Direct SSH is POSIX-only by design (like iOS): board writes, node actions and new sessions need the relay. iOS writes `project.json` over SSH for some of these; Android deliberately does not." android/README.md:20-22 also marks these as relay-only / read-only on SSH. Neither doc considers the finding's first fix, which is to open the relay just for these verbs. That fix is still valid. +> +> 2. **A user-facing workaround exists.** Settings → "How to reach each computer" → "Only through the relay" (SettingsScreen.kt:100-111) forces the relay route. New session and board writes then work on the LAN. The real defect is that the default Auto route hides them, and the missing FAB comes with no explanation (the board tab at least shows a hint). +> +> 3. **"A phone paired while remote access was off can never do either" is too strong.** If the user later turns on remote access, `adoptRelayIfAdvertised` (ConnectionManager.kt:165-187) mints a relay token over SSH. From then on, "Only through the relay", or being off the LAN, enables both features. The phone stays stuck only while remote access remains off. +> +> 4. **iOS's SSH parity is narrower than implied.** src/main/index.ts:3940-3942 says the iOS direct-SSH card move works "only for a project whose folder is on THIS machine and only while the whole file still fits in one argv string". CLAUDE.md:4789 measured this repo's own project.json at 114,695 bytes, about 15 KB under the 128 KB MAX_ARG_STRLEN ceiling. So the gap covers local folder projects under that size, not every project. +> +> **Better fix.** Keep SSH as the primary transport. For `registerNode`, `ensureBoard`, `setCardColumn`, `editCardLabels` and `node.*`, open the relay leg on demand when `host.relay` and a token exist. Where no relay leg exists, show why New session is unavailable instead of silently hiding the FAB. + +> Four corrections to the finding: +> +> 1. **Severity.** I would rate this medium, not high. It is a documented, deliberate gap (docs/android.md:95-98). A user whose computer has remote access on can work around it by setting the route to "Only through the relay" (SettingsScreen.kt:104). +> +> 2. **"Can never" is overstated.** A phone paired while remote access was off is not stuck forever. Late adoption (ConnectionManager.kt:165-187) mints a relay token from `~/.nodeterm/relay.json` once remote access is turned on. After that, relay-only routing or being off the LAN enables these features. Only while remote access stays off does the phone lose them for good. In that case iOS still has them over SSH. +> +> 3. **Two UX problems add to it.** +> - The FAB just disappears on SSH, with no reason shown. +> - The relayOnly error text (SshHostConnection.kt:261), "turn on remote access in nodeterm → Settings → Phone", is wrong when remote access is already on. The real problem is that the phone picked the SSH leg. +> +> 4. **Better fix.** In HostSession, when a relay-only verb is needed while `conn` is SSH and `host.relay` plus a stored relay token exist (often already minted by `adoptRelayIfAdvertised`), open a secondary relay connection lazily for `registerNode`, `ensureBoard`, `setCardColumn`, `editCardLabels` and `node.*`. Also show the FAB and board controls disabled with the reason, not hidden. The fallback is an SSH write that streams over stdin to a temp file, then does an atomic mv. That would also work with remote access off, but it must re-implement the kanban and node-append transforms, and it cannot reach SSH-project refs. + +**Continuation scope (`A90`, 2026-10-03):** the original `A26` relay-routing fix remains. +The user now needs an explicit plain shell on a manual SSH/WireGuard host with no local +workspace/relay. `A90` adds a phone-owned `nodeterm-phone` session and independent listing, +without registering on the desktop canvas or writing its shared project file. That A90 change +alone did not restore cold managed-agent sessions or supply Board/node/Git verbs over SSH. +At that checkpoint, implementation/tests/build/device verification were pending. The focused +plain-shell flow later passed on the Pixel; A107/A108 now provide the separate typed Git and +owned service paths described above. Their new physical matrices remain pending. + +## A27 + +**Cannot connect straight to a Linux dev host or a headless Server Edition (iOS's "phone SSHes into the host" setup)** + +- Severity: **medium**; claimed by auditor: high; effort: large; area: parity; kind: gap +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt:38` + +**Evidence** + +iOS reaches plain SSH hosts on its own: src/core/remote-push-grants.ts:7 ("phone --SSH--> Linux dev host <--SSH-- macOS nodeterm", called "the common desktop setup"). src/main/remote-ssh/remote-status-push.ts:5-9 says the per-project slice `~/.nodeterm/agent-status-.json` exists because "a phone browsing the host directly sees live tmux sessions but no agent states". docs/SERVER.md:81 describes a headless host that "gives a phone SSHing into it full push / Live-Activity coverage", and :142 says "the phone offers this per connection (it installs under whichever user its SSH session logs in as)". The mirror's `server` block is "surfaced to the phone so it can show the installed version / commit" (agent-status-mirror.ts:156-161). Android can only add a computer by scanning a desktop QR (PairScreen.kt; the only entry points are Scan / paste code). Its SSH browse looks for userData only in `~/Library/Application Support/nodeterm` and `~/.config/nodeterm` (SshScripts.kt:38), not the Server Edition's `~/.nodeterm-server` (src/server/config.ts:108). It lists only the `-L node-terminal` socket (SshScripts.kt:56), never `nodeterm-rmt` (Models.kt:228: "never the phone's target"), and never reads `agent-status-.json`. `serverVersion` is parsed (ProjectsParser.kt:138) but never shown. + +**Proposed fix** + +Add a manual "Add SSH server" flow (host, user, port; install the phone's Ed25519 key). Extend the SSH browse to find `~/.nodeterm-server`, list the `nodeterm-rmt` socket, and read `~/.nodeterm/agent-status-*.json` slices, treating a stale `updatedAt` as no data. Show the `server` block (version, commit, installedAt), and offer the install-server.sh one-liner per connection. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The core claim stands, but three details need adjusting. +> +> 1. **Severity should be medium, not high.** The headline Android flow is pairing with a desktop, and that works. The gap is complete only for SSH-only users and headless Server Edition users. That is a meaningful parity gap in a narrower case, not a feature broken for most users. +> +> 2. **"Cannot use the Android app with that machine at all" is too strong for the Mac-driven dev host case.** host-service.ts:157-159 notes that in some setups the phone has no credentials for the SSH host. A user whose Mac is paired can still reach that Mac through the relay (and through direct SSH to the Mac) and browse its projects, SSH projects included. The statement is fully true only for: +> - a headless Server Edition, which has no pairing service anywhere under src/server; +> - a user who only has SSH access to the dev host. +> +> Also, the iOS "add SSH host manually" flow is inferred from the desktop's SSH-only-phone code paths (index.ts:2136-2143, push-notify.ts:92-99). The nodeterm-ios source is not visible to confirm it. +> +> 3. **The proposed fix needs three refinements:** +> - **Track the socket per session.** Both `node-terminal` and `nodeterm-rmt` can exist on one host at once (the session-memory notes in CLAUDE.md). +> - **Make attach on `nodeterm-rmt` attach-only.** `SshScripts.attach` uses `new-session -A`. On `nodeterm-rmt` that would create a missing session without the desktop's hook and account `-e` environment and its remote tmux.conf. +> - **Discover projects differently on a Mac-driven host.** There is no `workspace.json` there. Node ids would come from the `agent-status-.json` slices, the `tmux ls` output and `/.nodeterm/project.json`, with a stale `updatedAt` read as "no data" (STATUS_HEARTBEAT_MS = 60 s). +> +> `~/.nodeterm-server` is only the default location. It can be changed with `--data-dir` or `NODETERM_DATA_DIR`, so the browse should check the default and treat a miss as "unknown", not as "no nodeterm here". + +> Real, with these corrections: +> +> - **Anchor.** The "only QR/paste" claim belongs at PairScreen.kt:97-115 (accept() at :67-75), not at SshScripts.kt. SshScripts.kt:38 is correct only for the userData probe. +> - **Stronger root cause for the Server Edition.** The issue is not only the missing `~/.nodeterm-server` probe. src/server has no pairing service at all, so no QR exists to scan and Android has no route whatsoever. +> - **Severity.** "High" is defensible for users of these two topologies. Everyone who pairs with a desktop by QR is unaffected, though, so medium–high (a whole-topology parity gap) is more precise than "headline feature broken for most users". +> - **Fix: key install.** The phone cannot install its own Ed25519 key before it can authenticate. The manual "Add SSH server" flow needs a bootstrap: a one-time password login, or showing the public key line for the user to add to authorized_keys. It also needs TOFU host-key pinning like the paired path. +> - **Fix: push.** `~/.nodeterm/push-grants` and the backend's /v1/push fan-out are APNs-only (docs/android.md Known gaps). Adding the host would give Android browse, terminal, status slices and polling, but not headless push. Do not promise the "full push / Live-Activity coverage" from docs/SERVER.md:81. +> - **Fix: status slices.** Reading the `agent-status-.json` slices should treat an `updatedAt` older than about 2× STATUS_HEARTBEAT_MS (60 s, remote-status-push.ts:22) as no data. +> - **Fix: nodeterm-rmt targets.** Attaching to nodeterm-rmt sessions must use that socket for attach, has-session, send-keys and kill as well, not only for list. + +## A28 + +**SSH-project sessions over direct SSH are attached, approved and resumed on the wrong machine** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: parity; kind: gap +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshScripts.kt:93` +- Note: Same issue as A09. + +**Evidence** + +SSH-project sessions run on the remote host. The desktop routes their approval answers there (`writePendingAnswer` writes `~/.nodeterm/pending/.answer` "on the project's host over its ControlMaster", src/main/remote-ssh/ssh-project.ts:1814-1823). CLAUDE.md also states "A remote node is NEVER spawned locally". iOS reaches such sessions on their own host (see the dev-host finding). Android lists them (SessionsTab.kt:111 keeps every open project; :236-238 labels them "on user@host"). Over the direct-SSH connection to the Mac it then runs `exec "$NT_TMUX" -L node-terminal … new-session -A -s 'nt-'` on the Mac (SshScripts.kt:93). That creates an empty local shell, reports `fresh=true`, and TerminalController offers `claude --resume ` in it. Approvals take the same wrong path: SshScripts.answerApproval checks the Mac's `~/.nodeterm/pending/.json` and prints `gone` when it is missing (:136). That maps to `ApprovalOutcome.ALREADY_HANDLED` (SshHostConnection.kt:268), and the UI toasts "Already handled." (InboxTab.kt:88) while the remote hook is still holding the request. + +**Proposed fix** + +Over SSH, refuse or relay-route nodes whose project has `sshTarget` (the relay's `approvals.answer` already routes through the desktop's `answerPermission`). Longer term, reach them by connecting to that host directly, as iOS does. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause and all cited lines are correct, but the proposed fix is only half right. +> +> **Approvals: relay-routing does fix them.** `approvals.answer` goes through `answerPermission`, which routes to the project's host. +> +> **Attach: relay-routing does NOT fix it.** The relay `pty.attach` has the same wrong-machine behaviour on the desktop side, where it pre-existed: +> - `host-service.ts` `handleAttach` (:419-432) calls `pty.sessionExists(nodeId)` and then `pty.attachDetached(nodeId, sinks, {cols, rows})`. +> - `PtyManager.attachDetached` (pty-manager.ts:2661-2667) passes only `persistKey`, with no `sshRemote` or `requireRemote`. `spawnSession` therefore runs a local `tmux new-session -A` on the Mac's `node-terminal` socket. +> - It is arguably worse on the relay. When the canvas holds a live remote session, `sessionExists` answers true via `liveSessionForPersistKey`, so the phone gets `fresh:false` over a brand-new empty local shell. +> +> **Better fix:** +> 1. On BOTH transports, refuse to attach to nodes whose project has `sshTarget`. Show something like "runs on user@host — open it there". The same applies to End session over SSH, which would kill a local `nt-` that never existed. +> 2. For approvals of such nodes, use the relay `approvals.answer` when a relay leg exists. Otherwise tell the user it must be answered on the computer. Do not fall through to OPEN_SESSION or "Already handled." +> 3. Separately, the desktop's relay attach path should refuse (or route over the ControlMaster) for nodes that `workspaceStore.sshProjectIdForNode` resolves to an SSH project. +> +> A precision note on wording: the resume is only offered, never typed automatically. Accepting it launches the agent CLI on the Mac, where the transcript does not exist. + +## A29 + +**Current follow-up:** A107 adds the same eight typed verbs over direct SSH with a physical project/repository jail and no uncertain replay. + +**No source-control screen, although the protocol layer already implements the git verbs iOS uses** + +- Severity: **medium**; claimed by auditor: medium; effort: medium; area: parity; kind: gap +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt:95` + +**Evidence** + +The phone vocabulary includes a source-control sheet: src/main/remote/host-service.ts:117-120 ("the jailed core bridge that lets a relay-only phone (no direct SSH) run the source-control sheet in ONE round trip per operation instead of N ssh execs"). src/main/index.ts:3903 ("The jailed core bridge both phone hosts serve: typed git verbs") and host-git-bridge.test.ts:1 ("The jailed core bridge for the PHONE vocabulary: typed `git.*` verbs") agree. "instead of N ssh execs" shows iOS also does source control over SSH. Android declares `suspend fun git(verb: GitVerb, cwd: String, …)` (HostConnection.kt:95; `GitVerb` STATUS/DIFF/STAGE/UNSTAGE/COMMIT/PUSH/PULL/HISTORY at :103), and RelayHostConnection advertises `git = true` (RelayHostConnection.kt:55). But no app code calls `.git(` (grep finds 0 call sites under android/app), and SSH throws `relayOnly("Source control")` (SshHostConnection.kt:258). + +**Proposed fix** + +Add a per-project Source Control screen over `conn.git(...)`: status list, diff viewer, stage/unstage, commit box, push/pull, history, with the host's error messages shown as returned. Add an SSH implementation that runs `git -C ` inside the project roots. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Three refinements: +> +> 1. **The gap is also undocumented.** docs/android.md has a protocol mapping table (lines ~33-45) and a "Known gaps" section (lines 88-104). Neither mentions git or source control, even though the doc says it records "what is not done". The fix should document the gap as well as close it. +> +> 2. **Better fix: relay first, then SSH.** Ship the screen over the relay first, following the pattern the app already uses for board writes, node actions and new sessions: gate it on `conn.capabilities.git` and hide it or show the relay-only reason on SSH. The relay path already exists (RelayHostConnection.kt:280). Its error contract is explicit: host-service.ts:514-523 answers "git is not served on this host." / "cwd is outside the shared project roots.", and the UI should show those strings as returned. An SSH leg (`git -C ` over the session) can follow as a separate step. If it is built, restrict cwd to the project roots from `projects.list`, to match the desktop's `isWithinRoots` jail, and use typed verbs only. Otherwise document it in Known gaps, the same way board writes are documented as relay-only. +> +> 3. **Line and evidence details.** The capability flag is at RelayHostConnection.kt:54-56 (the `HostCapabilities(...)` constructor, `git = true` on :55), and the implementation is at :280-281. The claim that iOS does source control over SSH is inferred from a desktop comment and is not verified. + +## A30 + +**Pressing Deny on the desktop is not respected: the phone re-dials about 8 s later and the SAS approval dialog reappears** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt:212` +- Note: Related: A05, A07. + +**Evidence** + +Desktop side: on Deny, standing-host.ts:398-404 (`ipcMain.on(IPC.remoteHostReject…)`) only calls removeFromPool(p) → session.close() and ensurePool(). There is no deny list. Phone side: the close surfaces in RelayConnector as the generic `HostException("The connection closed while waiting for approval.")` (RelayConnector.kt:70), so the phone cannot tell a rejection from a network drop. It becomes ConnState.Failed. While any screen watches the computer, the poll loop `while (isActive) { refreshNow(); delay(POLL_MS) }` (ConnectionManager.kt:212-221) calls refreshNow → ensureConnected → connectLocked again, with no backoff and no memory of the refusal. When SSH is unavailable (off-LAN, a Windows host, or the Relay-only route), each attempt does a fresh /v1/relay/join plus handshake on the replacement listener, and onPeerReady raises a new SAS dialog (standing-host.ts:258). The same loop re-raises the dialog every ~5 min 8 s after an unanswered approval times out (RelayConnector.kt:82). + +**Proposed fix** + +Treat a close during the approval wait as a terminal refusal for this HostSession. Stop the auto-reconnect and show "The computer declined this phone" with an explicit retry button. Back off after an approval timeout. On the desktop, remember a rejected key for some time instead of prompting again. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The claim is right but understates the scope in one respect, and a few details need adjusting. +> +> 1. The poll loop is not the only thing that redials. `InboxWorker.doWork` (InboxNotifier.kt:118-129) is a 15-minute `PeriodicWorkRequest` (:64). It calls `withTimeoutOrNull(45_000) { session.refreshNow() }` for every paired host, whether or not a screen is watching. A denied phone on the relay route therefore raises the SAS dialog again about every 15 minutes indefinitely, even with the app closed. Each of those prompts is abandoned 45 s later, when the timeout cancels the connect. A phone-side fix limited to `HostSession` or the poll loop would not stop this. The worker must also skip a host whose last relay attempt was refused or is still unapproved. +> +> 2. The timing depends on the real relay. "About 8 s" assumes the relay closes the phone's socket when the host closes its side. The test broker does (host-fixture.ts:76-78). If the real relay does not, the phone fails through the 30 s RPC timeout instead (RelaySocket.kt:326), so the redial comes after about 38 s. Either way the dialog comes back. +> +> 3. Line numbers: the loop body is ConnectionManager.kt:214-219, and :212 is the `startWatching` declaration. +> +> 4. The fix belongs mainly on the desktop. Any client can simply redial, so the security mitigation is to remember a rejected public key for a while in standing-host.ts or phone-approval.ts and not prompt for it again. Relatedly, `mintDevice` needs only the host's public key and hostDeviceId, which are advertised in relay.json. On the phone, the fix is UX: treat a close during the approval phase as a refusal, stop auto-reconnect in both the poll loop and the worker until the user taps retry, and back off after an approval timeout. +> +> 5. Severity: medium is reasonable. The case is limited to the relay route and a phone that was denied or left unanswered, but the effect is repeated prompting on a security dialog. + +## A31 + +**A silently dead SSH peer (laptop asleep, desktop IP or VPN change) wedges the host as 'On your network' for many minutes: no detection, no relay fallback, and the error is never shown** + +- Severity: **medium**; claimed by auditor: medium; effort: small; area: critic; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt:84` + +**Evidence** + +(1) Keepalive: `client.connection.keepAlive.keepAliveInterval = 20` (line 353) configures sshj's DefaultConfig provider. That provider is KeepAliveProvider.HEARTBEAT (javap of sshj-0.39.0 DefaultConfig), and Heartbeater.doKeepAlive only writes an SSH_MSG_IGNORE and never expects a reply, so it cannot detect a dead peer. The sshj Reader also catches SocketTimeoutException and continues, so `client.timeout = 30_000` does not help either. (2) run(): `val out = cmd.inputStream.readBytes()` runs before `cmd.join(timeoutSec…)` (lines 88-89), and ChannelInputStream.read uses a bare Object.wait(). The timeout never bounds the read, so a command in flight when the peer vanished blocks forever. (3) Every other failure, such as startSession timing out, is wrapped as `HostException("The SSH connection failed…")` (line 96). fireClosed runs only `if (!isConnected)`, which stays true. ConnectionManager.refreshNow treats any HostException as an 'answer' and does NOT disconnect (ConnectionManager.kt:195-203), so the dead connection is reused on every poll and connectLocked's relay fallback is never reached. (4) The only error sink, `_lastError` (ConnectionManager.kt:53-54, 198), is never read by any UI (grep: no reference outside ConnectionManager.kt). + +**Proposed fix** + +Use `KeepAliveProvider.KEEP_ALIVE` (keepalive@openssh.com with a max missed count) in the SSHClient config. Bound run() with a real deadline (read on a worker thread, or close the channel on timeout). In run() and refreshNow, classify channel-open and transport timeouts as transport failures and disconnect, so Auto falls back to the relay. Surface lastError in HostScreen. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Three refinements, none of which changes the verdict. +> +> (a) "Heartbeater cannot detect a dead peer" is slightly overstated. Its IGNORE writes are exactly what make the kernel eventually time out an otherwise idle connection. Detection does happen, but only after the TCP retransmission timeout (about 15 minutes), never within an app-level bound. It is not "never". +> +> (b) A laptop that wakes inside the retransmission window resumes the same TCP connection, so the sleep case is a long stale window rather than a permanent wedge. The desktop IP-change and VPN cases really are dead until the kernel gives up. +> +> (c) The InboxWorker impact is narrower than stated. For an unwatched host it calls `session.disconnect()` after every run (InboxNotifier.kt:127), and a fresh `connect()` uses a 4 s connectTimeout under Auto, so it falls back to the relay correctly. It only loses time when it reuses a dead conn that was left open: stopWatching does not disconnect. It hangs past its 45 s timeout only if a read was in flight. +> +> Fix details. Set `KeepAliveProvider.KEEP_ALIVE` on the DefaultConfig BEFORE constructing SSHClient. KeepAliveRunner sends keepalive@openssh.com with want-reply and calls `transport.die()` after `maxAliveCount` misses, which fires the existing disconnectListener → fireClosed → relay fallback. Also bound the read in run() by closing the session or channel from a timer: channel close/notifyError unblocks the wait. Treat a channel-open timeout (ConnectionException) as a transport failure that disconnects, rather than as an "answer" HostException. Show lastError in the HostScreen banner. + +## A32 + +**No way to open a URL or copy text from the phone terminal: no link detection, no touch selection, and the WebView cannot show xterm's link confirm** + +- Severity: **medium**; claimed by auditor: medium; effort: medium; area: critic; kind: gap +- Location: `android/app/src/main/assets/terminal/terminal.js:22` + +**Evidence** + +terminal.js loads only the FitAddon (`term.loadAddon(fit)`, line 22). There is no WebLinksAddon and no `linkHandler`, so plain URLs in agent output are never linkified. OSC 8 links fall back to xterm's default activate, which calls confirm(). The WebView gets only a WebViewClient and no WebChromeClient (TerminalController.kt:175), so that dialog cannot appear and the link does not open. The shouldOverrideUrlLoading branch that 'opens links in the browser' (TerminalController.kt:177) is effectively unreachable. Selection is also unavailable: the vendored xterm.js handles touchstart/touchmove only for viewport scrolling, disables its selection service while mouse events are active (tmux runs `mouse on`), and xterm.css sets `.xterm { user-select: none }`. terminal.js additionally calls `e.preventDefault()` on every single-finger touchmove (line 80). The only copy path is OSC 52 emitted by the host, and on a touch phone the only way to trigger it is tmux copy-mode driven by mouse drag or prefix keys, neither of which is reachable. README.md:18 nevertheless advertises 'OSC 52 copy'. + +**Proposed fix** + +Load @xterm/addon-web-links with a handler that calls the bridge to open http(s) URLs. Set a linkHandler that skips confirm(), or install a WebChromeClient. Add a long-press 'select / copy screen' mode, e.g. capture the visible buffer via `term.buffer` and offer copy/share of lines, since tmux mouse mode makes xterm's own selection unusable. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Four parts of the finding need correcting. +> +> 1. Two copy paths do exist, but neither is practical. The finding says OSC 52 copy can't be reached from a touch phone; that is not strictly true. +> - Prefix keys: the key row has a Ctrl chip (TerminalScreen.kt:153). TerminalController.onInput turns Ctrl+letter into a control byte (TerminalController.kt:138-141). So Ctrl, b, [ enters tmux copy-mode (the default C-b prefix; the conf sets none). From there a keyboard selection and copy are possible but awkward and hard to discover. +> - Double/triple tap: tmux 3.4's built-in root bindings are `bind -n DoubleClick1Pane { ... if -F '#{||:#{pane_in_mode},#{mouse_any_flag}}' { send -M } { copy-mode -H; send -X select-word; ...; send -X copy-pipe-and-cancel } }`, and the same for TripleClick1Pane (select-line). When the pane's app has not grabbed the mouse (a plain shell, codex), the clicks the WebView synthesizes from taps may therefore copy a word or line via OSC 52. This is not verified on a device. In a Claude pane, where the fullscreen TUI grabs the mouse, the click goes to the app instead. +> So the accurate statement is: there is no practical or general copy path, and never a selection of arbitrary text. That is not the same as "none at all". +> +> 2. The OSC 8 part is stronger than the finding shows. Links do reach the phone because pty-manager.ts:326 declares `,*:hyperlinks`. +> +> 3. Proposed fix, links: +> - Do not load @xterm/addon-web-links. The desktop replaced it on purpose: the addon cannot join the hard-wrapped rows that tmux repaints, so a long OAuth URL only matched its first row (src/renderer/terminal/file-links.ts:404-409). +> - Port createUrlLinkProvider and set `term.options.linkHandler` to a handler that calls a new NodetermBridge.openUrl, limited to http(s). +> - Account for the fact that, with mouse reporting on, a tap on a link is also sent to tmux as a click. +> +> 4. Proposed fix, copy: a long-press "copy screen" or "select lines" sheet built from `term.buffer.active` remains the right approach. +> +> The line citations in the finding are correct: terminal.js:22, :80; TerminalController.kt:175, :177; README.md:18. + +## A33 + +**On a Windows computer, 'New session' starts the agent in the user's home folder instead of the project, and silently drops the chosen Claude account while still registering it** + +- Severity: **medium**; claimed by auditor: medium; effort: medium; area: critic; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/Agents.kt:28` + +**Evidence** + +The relay attach that creates a phone-started session passes only `{cols, rows}` (host-service.ts:432 `pty.attachDetached(nodeId, sinks, { cols, rows })`). The desktop therefore spawns it in `options.cwd || os.homedir()` (pty-manager.ts:2834), and the session-host backend receives that same `cwd` (pty-manager.ts:3346). The phone's only way to reach the project is the launch line. But `SAFE_DIR = Regex("^/…")` (Agents.kt:28) rejects every Windows path (`C:\Users\…`), so `cd '' &&` is omitted (Agents.kt:52). Likewise `CLAUDE_CONFIG_DIR=''` is omitted for a Windows account dir (Agents.kt:54), and the shell variant's `Regex("^/…")` returns null (SessionsTab.kt:331). TerminalController still registers the node with `launch.accountId` (TerminalController.kt:238). Windows computers are relay-only, and New session is offered only over the relay (HostScreen.kt:71), so Windows is exactly the platform that takes this path. + +**Proposed fix** + +Have the host own the cwd: extend `pty.attach` or `projects.registerNode` so the desktop creates a phone-started session in the project's cwd with the account env (the desktop already knows both). Failing that, emit a platform-appropriate launch line for a win32 host (the mirror or projects blob can carry the host OS). Never register an accountId the launch did not actually apply. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Mostly accurate. Three refinements. +> +> (1) "New session is offered only over the relay" is true for every host OS (HostScreen.kt:71). Windows is the platform where that relay-only path also loses the cd/env. Mac and Linux relay launches get `cd '/path' &&` and work. +> +> (2) The finding's alternative fix is weaker than it sounds. A "platform-appropriate launch line" is not enough on its own. The Windows session host runs the default Windows shell (localSessionShell). There, `cd 'C:\x' && ...` is a parse error in PowerShell 5.1, cmd does not accept single-quoted paths, and the `VAR=value cmd` env prefix is POSIX-only. So simply relaxing SAFE_DIR would make things worse, not better. +> +> (3) The primary proposed fix should not be `projects.registerNode`. The phone deliberately registers AFTER typing the launch line (TerminalController.kt:231-238), so the node does not exist when `pty.attach` creates the session. The right seam is to extend `pty.attach` (host-service.ts:382-433) with an optional projectId and accountId. The host would resolve the cwd from its own project registry and the account env via `claudeConfigDirFor`, validated the same way the canvas does. Until that exists, the phone should neither offer the account picker nor send `accountId` to registerNode when the launch line could not apply CLAUDE_CONFIG_DIR. + +## A34 + +**The Ctrl key-row chip does not apply to text sent from the input bar: arming Ctrl and sending 'z' submits a literal 'z' plus Enter** + +- Severity: **medium**; claimed by auditor: low; effort: small; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:136` + +**Evidence** + +The Ctrl transform only applies when `ctrlArmed && data.length == 1` (TerminalController.kt:136-144). The input bar sends through `nt.submit` → `term.paste(text)` (terminal.js:129-133). Under a tmux client xterm always has bracketed-paste mode on (CLAUDE.md: tmux's own paste-through `?2004h`), so onData receives `\e[200~z\e[201~` (length > 1) and the Ctrl is not applied. The following '\r' (length 1, outside '@'..'_') then disarms Ctrl. The other way to type into xterm directly, the soft keyboard via the ⌨ chip, does not work (already reported). + +**Proposed fix** + +Apply an armed Ctrl in Kotlin before submitting: if the draft is a single character, send the control byte via `raw()` and skip Enter. Otherwise disarm the chip when the input bar is used. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> 1. **Wrong line numbers for terminal.js.** The file has 121 lines. `nt.submit` is at terminal.js:110-114 (`term.paste(text)` at 112, the delayed `onInput('\r')` at 113), not 129-133. The onData hookup is at terminal.js:50. The Ctrl condition is TerminalController.kt:138-142 (the finding's line 136 is the `fun onInput` declaration). The input bar is TerminalScreen.kt:124-139 and the chip is at TerminalScreen.kt:153. +> +> 2. **The impact is slightly overstated.** "Only the four ^C/^D/^R/^L chips work" is not quite right. The chip does apply to single keystrokes typed straight into xterm: tapping the terminal to bring up the soft keyboard, per the comment at terminal.js:48-50. Whether that works with Android IMEs is a separate question. The bug is specifically that the chip has no effect on the input bar, which is the main text entry. Low severity is appropriate. +> +> 3. **The fix should drop Enter in every case.** Check ctrlArmed in the Kotlin send handler: if the draft is one character that maps to a control byte (`uppercase in '@'..'_'`, optionally `'?'` → 0x7f), send it via `raw()` with no Enter, then disarm. Otherwise disarm when the input bar is used. Point 6 above is why Enter must be dropped even when bracketed paste is off. + +## A35 + +**approvals.answer returns `answered:false` both for 'already handled' and for 'the write failed'; the phone always says 'Already handled.'** + +- Severity: **medium**; claimed by auditor: low; effort: small; area: critic; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/RelayHostConnection.kt:255` +- Note: Related: A06. + +**Evidence** + +Android: `return if (body?.b("answered") == true) SENT else ALREADY_HANDLED` (RelayHostConnection.kt:255), and InboxTab shows the toast "Already handled." (InboxTab.kt:88). Desktop, the verb added in this branch: `.then((answered) => …{ answered })` and `.catch(() => …{ answered: false })` (host-service.ts:724-725). answerPermission returns false when the write fails: writePendingAnswerLocal's `catch { return false }` (pending-approvals.ts:56-58), and for an SSH-project node `sshProjectManager.writePendingAnswer` fails whenever its ControlMaster is down (index.ts:2782-2785). The phone's stillWaiting re-read has just confirmed the node is still blocked. + +**Proposed fix** + +Return a reason from the verb (`{answered:false, reason:'gone'|'write-failed'}`) and show 'Couldn't answer — try again or open the session' for a failed write. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause is stated a little wrong. The desktop does not return `answered:false` for "already handled" at all: `answerPermission` never checks whether the hook is still holding the request, so on the relay path every `answered:false` is a delivery failure (write error, no SSH conn record, ssh exec failure, or a throw). The desktop comment at host-service.ts:691 and the test header at host-inbox-verbs.test.ts:6-7 are wrong to say otherwise. A related side effect: answering an already-timed-out hook returns `answered:true` (SENT), which the SSH path avoids. +> +> Better fix, matching the SSH script's semantics: +> - In `answerPermission`, test for `.json` first (local: stat under `pendingDir(homedir())`; SSH: `test -f` over the ControlMaster). +> - Return a discriminated result: `{answered:true} | {answered:false, reason:'gone'|'write-failed'}`. +> - Map only `reason:'gone'` to ALREADY_HANDLED. +> - Map `write-failed`, and an absent reason from an older desktop, to an error toast such as "Couldn't answer — try again or open the session", the same way `SshHostConnection.answerApproval` throws HostException for a failed write. + +## A36 + +**After any connection drop the terminal stays on 'Disconnected. [Reattach]' even though the host connection reconnects by itself** + +- Severity: **medium**; claimed by auditor: low; effort: small; area: critic; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:105` + +**Evidence** + +On a relay drop, RelayHostConnection.onClosed calls `sink.onExit(null)` for every stream (RelayHostConnection.kt:121-126). TerminalController then sets `state = TermState.Ended("Disconnected.")` (TerminalController.kt:105-110). HostSession reconnects automatically 1.5 s later when watched (ConnectionManager.kt:141-149), but that only re-lists projects. TerminalController does not observe `session.state`, and re-attaching needs the manual Reattach button (TerminalScreen.kt:95). The SSH stream behaves the same way (`sink.onExit(cmd.exitStatus)` with a null status). + +**Proposed fix** + +When the exit code is null (a transport drop, not a pane exit), have TerminalController wait for the HostSession to return to Connected and call attach() again automatically with bounded retries. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The mechanism is right. Four refinements, the last two about the fix: +> +> 1. **SSH detection is slower.** A dead SSH channel does not null `HostSession.conn` straight away. `fireClosed` runs only when the next `run()` fails (SshHostConnection.kt:84-98), which is usually the next 8 s poll. On SSH the host-level reconnect therefore follows the drop by up to about 8 s, not 1.5 s. An automatic reattach attempted before that point goes through `ensureConnected()`, gets the stale connection back, and its `hasSession` `run()` throws a HostException. That lands the terminal in Ended again, this time with "Couldn't open the terminal over SSH…". +> 2. **Relay reconnect can stall at approval.** The relay re-dial can pass through `ConnState.AwaitingApproval` (ConnectionManager.kt:114). Waiting for `Connected` is correct, but the wait should have no short fixed deadline. +> 3. **A null code is not proof of a transport drop.** A relay `Op.ERROR` frame whose `exitCode` is JSON null also yields `onExit(null)` (RelayHostConnection.kt:68-71; the desktop forwards the pty's `exitCode` verbatim at host-service.ts:342-349). A cleaner signal is to mark streams closed by the connection-level `onClosed` path, or to key the retry on `session.state` leaving `Connected`. +> 4. **Keep the retry bounded and conditional.** Retries should be capped, and they should run only while the screen is showing (not `disposed`), so a real host-side failure cannot loop. + +## A37 + +**proguard-rules.pro would not survive turning on minification (R8 missing-class errors)** + +- Severity: **low**; claimed by auditor: low; effort: small; area: build; kind: risk +- Location: `android/app/proguard-rules.pro:3` + +**Evidence** + +app/build.gradle.kts l.21-23 keeps `isMinifyEnabled = false` and says shrinking 'needs keep rules nobody has written yet'. proguard-rules.pro says: 'If it is ever turned on, sshj, BouncyCastle and EdDSA need keeps …' and adds only `-dontwarn javax.naming.**` and `-dontwarn org.slf4j.**`. jdeps on the actual runtime jars shows references to classes android.jar does not have: sshj 0.39.0 `SSHClient` and `userauth.method.AuthGssApiWithMic` reference `org.ietf.jgss.*` (GSSContext, GSSManager, Oid, …) and `javax.security.auth.login.LoginContext`, and eddsa 0.3.0 `EdDSAEngine` references `sun.security.x509.X509Key`. R8 in AGP 8 fails the build on missing classes unless they are `-dontwarn`ed. + +**Proposed fix** + +Add `-dontwarn org.ietf.jgss.**`, `-dontwarn javax.security.auth.login.**` and `-dontwarn sun.security.x509.**`, and drop the unnecessary `org.slf4j` dontwarn (slf4j-api is on the classpath). Alternatively, change the comment so it no longer implies the rules are complete. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding holds, with two corrections. +> +> 1. It overstates the framing. The file does not really present itself as a ready-made, complete rule set. build.gradle.kts:21 says outright "shrinking needs keep rules nobody has written yet", and proguard-rules.pro:1-2 only says keeps "need" to exist. The real inconsistency is smaller: that comment says the rules have not been written, while proguard-rules.pro:3-7 does contain partial keeps. Those keeps would fail R8 on missing classes as soon as they were used. +> +> 2. A better anchor is proguard-rules.pro:6-7 (the dontwarn block), not line 3. The fix: +> - Add `-dontwarn org.ietf.jgss.**`, `-dontwarn javax.security.auth.login.LoginContext` (or `.**`) and `-dontwarn sun.security.x509.**`. +> - `-dontwarn org.slf4j.**` is redundant but harmless, because slf4j-api is a declared runtime dependency of sshj in both its POM and its .module file. It is a nit, not a required part of the fix. +> +> It is unclear whether the result then passes R8. That needs an actual `assembleRelease`, which cannot run here because Google Maven is blocked. Severity: low (latent). The wording "would break assembleRelease" is accurate only once someone enables minification. + +## A38 + +**Quick approve requires the node to be exactly 'blocked', but the desktop publishes approval tickets while the node stays 'waiting' on a held question** + +- Severity: **low**; claimed by auditor: low; effort: small; area: protocol; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt:53` + +**Evidence** + +Android: `if (status.state != expected) return false`, with `expected = AgentState.BLOCKED` for approvals (`WAITING` for questions), which returns ALREADY_HANDLED. Desktop agent-status-mirror.ts:1520-1528: when a parent AskUserQuestion is pending and a concurrent approval arrives, `produceInboxFromState(... 'blocked' ...)` publishes the approval card with its pendingId, but the mirror keeps `state: next.state` = 'waiting' ('keep the parent's waiting badge and question correlation until its own answer'). Conversely, line 1611 (`kind = hasQuestion || nextState === 'waiting' ? 'question' : 'approval'`) can emit a question card while the state is 'blocked'. + +**Proposed fix** + +For approvals, check that the event is still unresolved and that its pendingId is still live, and accept either needs-you state (blocked or waiting). Do the same for questions. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line citations (mirror 1520-1528 and 1611) are correct. Restrict the finding to the approval direction. The question-while-'blocked' half needs an uncorrelated question and is not reachable in the normal Claude flow after #821. +> +> The proposed fix is too broad and would be harmful as written. Relax the node-state gate ONLY on the ticketed path. When `event.pendingId != null`, skip the `state == BLOCKED` requirement. Keep only the "fresh event is still unresolved" re-check that hook-reply-approvals.md prescribes, then call `conn.answerApproval`; the desktop or the answer file reports "gone" when the ticket expired. Do NOT accept WAITING for the ticketless send-keys fallback (claude, no pendingId), and do not accept BLOCKED for answerQuestion. On a node whose AskUserQuestion picker is on screen, typing `1` would pick option 1 of the question rather than approve anything. + +## A39 + +**The account chip reads `account.label`, which the mirror never writes, so it falls back to the raw account UUID** + +- Severity: **low**; claimed by auditor: low; effort: small; area: protocol; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt:129` + +**Evidence** + +Android: `accountLabel = account?.let { it.s("label") ?: it.s("accountId") }`. The mirror's `account` is `ObservedClaudeAccount` = `{configDir, accountId: string|null, known, remote?}` (shared/types.ts:1348-1370), which has no `label`. MirrorSettings.claudeAccounts carries only `{id, dir}`. SessionsTab.kt:265 renders `status?.accountLabel`. + +**Proposed fix** + +Derive the label as the desktop's AccountChip does: for a known managed id, look up a label (or show a short form); for an unlinked dir, use the last segment of configDir. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Three small corrections. Nothing here changes the verdict. +> +> (1) It is not a separate chip. The value is joined into the Sessions row's " · "-separated detail text (SessionsTab.kt:260-267), so the UUID mostly shows up as ellipsized noise at the end of the line. +> +> (2) The parse site starts at ProjectsParser.kt:121 (`val account = e.o("account")`). The fallback is on line 129. +> +> (3) Better fix: the mirror already carries labels for local managed accounts. `usage.accounts[]` has `{accountId, label, email}`, built from settings (agent-status-mirror.ts:312-317, `label: acct?.label ?? null`), and Android already parses that block (ProjectsParser.kt parseUsage → `UsageAccount.label`, used in InboxTab.kt:236). So the fix is: +> - Resolve `account.accountId` against `status.usage.accounts`, using label, then email. +> - If there is no match, show a short form of the id rather than the full UUID. `usage` is dropped from SSH slices, and it is absent when no usage snapshot exists. +> - When `accountId` is null and `known` is false, show the last path segment of `configDir`. +> - When `accountId` is null and `known` is true, show nothing (the system account). +> +> Dropping the `label` read entirely would also remove dead code. + +## A40 + +**The pending-launch path and the attach hand-off never re-check `disposed`: a stream can stay attached forever, or a phone-started node gets registered with no agent launched** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:234` + +**Evidence** + +- attach() checks `if (disposed)` once, on the background thread (:209), then posts `main.post { stream = s; state = TermState.Attached ... }` (:213). If dispose() ran on main between that check and the posted runnable, the runnable installs `s` on a disposed controller, and nothing ever detaches it. +- afterAttach (:231-242) runs on graph.scope: `delay(900)`, then `s.write(launch.command + "\r")`, then `conn.registerNode(...)`, with no disposed or stream check. If the user leaves during those 900 ms, dispose() has already detached the stream (relay `pty.kill`), so the launch line is dropped. registerNode still runs, against a tmux session the phone's attach already created. + +**Proposed fix** + +Re-check `disposed` inside the main.post and detach if it is set. In afterAttach, abort (and don't registerNode) if the controller was disposed or the stream has changed since the delay started. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The mechanism is right, with three clarifications and a better fix. +> +> 1. (a) and (b) cannot both happen on the same attach. In the (a) race the stream is NOT detached, so the pending launch actually succeeds (the write and registerNode both land). The launch is lost only in the (b) window, where the posted runnable already installed the stream and dispose() then detached it. +> +> 2. The launch is also unrecoverable. PendingLaunches.take (:229) already consumed the request, so reopening the terminal cannot retry it. +> +> 3. Cited lines: the (a) window is between TerminalController.kt:209 and :213. The unguarded launch sequence is :234-241. +> +> Better fix for (b): the proposed "abort and don't registerNode" still leaves the `nt-` tmux session that the attach created (`new-session -A`) running as an invisible orphan with a bare shell. Either: +> - let the launch finish regardless of the viewer: dispose() defers the detach of a stream that still has a pending launch until afterAttach has written the line and called registerNode; or +> - on abort, call `s.endSession()` (`pty.destroy`) so the created session is killed, rather than just skipping registration. +> +> Fix for (a): in the main.post runnable, re-check `disposed` and detach `s` if it is set. Mark `disposed` @Volatile, or cancel attachJob from dispose(). + +## A41 + +**The composed prompt is cleared even when no stream is attached, so the text is silently lost** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt:132` + +**Evidence** + +TerminalScreen.kt:131-138: `controller.submit(draft, enter = true); draft = ""`, with no state check. submit → JS `term.paste(text)` → `bridge.onInput(d)` → TerminalController.kt:143 `stream?.write(out)`. stream is null while Connecting or after Ended ("Disconnected."), so the bytes are dropped. + +**Proposed fix** + +Disable Send (or keep the draft and show a message) unless controller.state is Attached. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is correct. On the line reference: the handlers span TerminalScreen.kt:131-138 (onSend at 131-134, the IconButton at 135-138), not just line 132. The same silent drop affects the key-row chips and the ^C/^D chips (`key()` goes to JS and then `bridge.onInput`; `raw()` calls `stream?.write` at :289-291), but those are single keystrokes and lose almost nothing. +> +> Suggested fix: make `submit` return whether it was delivered, e.g. `fun submit(...): Boolean { if (state != TermState.Attached) return false; ... ; return true }`, and clear `draft` only on true. Alternatively, set `enabled = controller.state == TermState.Attached` on the Send IconButton and ignore onSend when not attached. +> +> Gate on `state` rather than reading `stream`. The two are updated together in the same main-thread post (:213-215, :107-108), while `stream` is also read from the JS bridge thread without synchronization. + +## A42 + +**NewSessionDialog crashes if the selected project disappears while the dialog is open** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt:327` + +**Evidence** + +`val projects = snapshot.openProjects().filter { it.sshTarget == null }` is recomputed from the live snapshot that HostScreen re-lists every 8 s, while `projectId` is remembered. The Start button does `val p = projects.first { it.id == projectId }` (:327), which throws NoSuchElementException inside onClick when that project was closed or removed on the desktop meanwhile. + +**Proposed fix** + +Use firstOrNull and dismiss or show an error; alternatively disable Start when projectId is not in `projects`. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line (SessionsTab.kt:327) and the root cause are correct. A more precise fix: work out the selection from the current list, e.g. `val selected = projects.firstOrNull { it.id == projectId } ?: projects.firstOrNull()`. Enable Start only when `selected != null`, and use `selected` in onClick instead of `first {}`. That way the dialog never crashes. It also avoids a subtle trap: if the remembered id disappears and no radio button is shown as selected, Start should not go to a hidden project. If all local open projects are gone, disable Start or dismiss the dialog. + +## A43 + +**The HostScreen tab and scroll position reset after returning from a terminal** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/HostScreen.kt:62` + +**Evidence** + +`var tab by rememberSaveable { mutableIntStateOf(initialTab) }` (:62). AppContent (MainActivity.kt:112-118) renders only `nav.current` and has no SaveableStateHolder, so HostScreen leaves composition when a Terminal is pushed and its saveable state is dropped. The normal push is `Route.Host(host.id)` (tab 0). + +**Proposed fix** + +Wrap each route in rememberSaveableStateHolder().SaveableStateProvider(key), or store the selected tab back into the Route on change. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Root cause and line are right. The proposed fix is incomplete, though. `rememberSaveableStateHolder().SaveableStateProvider(key)` only preserves `rememberSaveable` state. The Board tab's selected project is a plain `remember` (`var projectId by remember { mutableStateOf(null) }`, BoardTab.kt:86), and so is the Inbox archive toggle (`showArchive`, InboxTab.kt:68). Both would still reset to the first project / off after returning from a terminal. They need to become `rememberSaveable`, or be hoisted into the Route. Also, the SaveableStateProvider key must be unique per stack entry (for example a per-entry id), not the Route data class, which can repeat on the stack. And `removeState(key)` must be called on pop, or the saved state leaks. The simpler alternative is to write the selected tab back into the stack entry (replace `stack[i]` with `r.copy(tab = newTab)`). That fixes the tab, but not the scroll positions or BoardTab's projectId. + +## A44 + +**System back discards Settings edits (device name, relay API base)** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SettingsScreen.kt:59` + +**Evidence** + +The name and apiBase are persisted only in the top-bar back IconButton (:59-63: `graph.hosts.deviceName = name; if (apiBase.startsWith("https://")) graph.hosts.apiBase = apiBase; nav.pop()`). The system back gesture goes to AppContent's `BackHandler(enabled = nav.stack.size > 1) { nav.pop() }` (MainActivity.kt:111), which pops without saving. + +**Proposed fix** + +Persist on change (or on dispose), or register a BackHandler inside SettingsScreen that saves before popping. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is accurate. On the fix: the simplest change that keeps the https-only rule for apiBase is a `BackHandler { save(); nav.pop() }` inside SettingsScreen. The innermost enabled BackHandler wins, so it takes precedence over AppContent's. It should share one `save()` lambda with the IconButton at :59-63. Saving on every keystroke would be wrong for apiBase: the HostStore setter would persist half-typed values, and the `startsWith("https://")` check would let through intermediate values like "https://a". So if you save outside the back action, use a DisposableEffect onDispose save rather than a per-keystroke write. One more point: when the typed apiBase is not https, the top-bar back also drops it without a message. That is intended validation, but the user is not told. + +## A45 + +**No onRenderProcessGone handler on the terminal WebView** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: risk +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:175` + +**Evidence** + +The WebViewClient (:175-184) overrides only shouldOverrideUrlLoading. Per WebViewClient.onRenderProcessGone, the default returns false, and then "application will crash if render process crashed, or be killed if render process was killed by the system". Because of the missing lifecycle handling, terminal WebViews are also kept alive in the background. + +**Proposed fix** + +Override onRenderProcessGone: return true, drop the WebView, and set the controller to Ended ("Reopen terminal") so a new WebView can be created. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The cited line, root cause and severity are right. The proposed fix is incomplete. +> +> 1. Returning true from `onRenderProcessGone` and setting `TermState.Ended` is not enough on its own. The existing Reattach button (TerminalScreen.kt:95) calls `controller.attach()`, which reuses the controller but never rebuilds the view. The `AndroidView` factory runs once per composition (TerminalScreen.kt:79), so no new WebView is created. After `webView = null`, `js()` returns silently (TerminalController.kt:192-193): a re-attached stream would paint into nothing, and `pageReady`/`pendingJs` would stay stale. +> +> 2. The handler must also: +> - detach the current `stream`; +> - remove the WebView from its parent and call `destroy()`; +> - reset `pageReady`, `pendingJs` and `cols`/`rows`; +> - bump a generation counter that keys the `AndroidView` (e.g. `key(gen) { AndroidView(...) }`), so Compose builds a fresh WebView before attaching again. +> +> 3. Optionally, call `setRendererPriorityPolicy(RENDERER_PRIORITY_BOUND, true)` so a backgrounded renderer is reclaimed ahead of the app, now that the app survives its loss. + +## A46 + +**The ⌨ key-row chip only focuses the DOM textarea, which cannot raise the soft keyboard** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:293` + +**Evidence** + +`fun focusTerminal() = js("nt.focus()")` → terminal.js `focus: function () { term.focus() }`. Nothing in the app calls webView.requestFocus() or InputMethodManager.showSoftInput (grep finds neither). A script-initiated focus() from evaluateJavascript is not a user gesture, and the Android IME attaches to the focused View (the Compose text field, or nothing). + +**Proposed fix** + +In focusTerminal, call webView.requestFocus() and then InputMethodManager.showSoftInput(webView, 0) alongside the JS focus. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The conclusion and the line (TerminalController.kt:293, triggered from TerminalScreen.kt:171) are correct. The root-cause wording needs adjusting. Blink does not require a transient user gesture here. Script focus is allowed in a main frame (Frame::AllowFocusWithoutUserActivation returns true outside fenced frames), and the keyboard is gated only on STICKY activation, meaning any gesture since page load. The chip actually fails for three reasons: +> (a) Element::Focus returns immediately when the textarea is already the focused element, which is the normal state after the user has tapped the terminal once. +> (b) Sticky activation is missing if the user has never touched the page. +> (c) The WebView is not the focused Android view while the Compose draft field has focus. +> +> Better fix: +> 1. In focusTerminal, clear the Compose focus first (LocalFocusManager.clearFocus() from TerminalScreen) so the draft field lets go of the IME. +> 2. Call webView.requestFocus(). +> 3. Blur and then refocus the textarea (`nt.blur(); nt.focus()`), so Blink's already-focused early return does not swallow the call. +> 4. Post InputMethodManager.showSoftInput(webView, InputMethodManager.SHOW_IMPLICIT) with webView.post {} so it runs after the view has become the IME's served view. A synchronous call right after requestFocus can race. +> +> Relying on showSoftInput is what makes this work regardless of the page's activation state. Needs a device check, since this was never built or run. + +## A47 + +**The Keystore decrypt runs on the main thread in the host list's composition, once per row per recomposition** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/HostsScreen.kt:121` + +**Evidence** + +Inside each LazyColumn item, `routeSummary(host, graph.secure.getString(SecureStore.relayTokenKey(host.id)) != null)`. SecureStore.getBytes (SecureStore.kt:56-67) runs Cipher.init + doFinal with an AndroidKeyStore key, which is a binder round trip to keystore2. The row recomposes on every ConnState change (Connecting/AwaitingApproval/Connected/Failed) as well as on list changes. + +**Proposed fix** + +Store a non-secret `hasRelayToken` flag on the host record, or compute it once in a remembered/background-loaded state. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The mechanism, line and severity (low) are all correct. Three additions: +> +> 1. **Lock contention.** The `@Synchronized` monitor in SecureStore (lines 44, 52, 70, 76) also makes this composition wait on any concurrent background SecureStore call, such as ConnectionManager.kt:101, 167, 181 or 183. +> +> 2. **Wasted decrypts.** The decrypt runs even for hosts with `host.relay == null`, where routeSummary ignores the result. +> +> 3. **Better fix.** Store a non-secret `hasRelayToken` boolean on PairedHost, or check only `prefs.contains(key)` without decrypting. Either one: +> - moves the check off the Keystore entirely; +> - updates correctly through the existing `graph.hosts.update` that follows `putString` in adoptRelayIfAdvertised (ConnectionManager.kt:183-186). +> +> A plain `remember { }` would still run the decrypt on the main thread once per row. If the decrypt must stay, use `produceState` on `Dispatchers.IO` keyed on `(host.id, host.relay)`. + +## A48 + +**The seen-events set is trimmed in hash order and updated without synchronization, which can produce duplicate notifications** + +- Severity: **low**; claimed by auditor: low; effort: small; area: runtime; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/data/HostStore.kt:98` + +**Evidence** + +`fun markSeen(ids) { val next = (seenEvents() + ids).toList().takeLast(500).toSet(); prefs.edit().putStringSet("seenEvents", next).apply() }`. getStringSet returns a HashSet, so takeLast(500) drops arbitrary old ids rather than the oldest. The method is not @Synchronized and is called from the worker thread (InboxNotifier.kt:86), from graph.scope (TerminalController.kt:262) and from main (InboxTab.kt:76). Concurrent read-modify-write loses updates. + +**Proposed fix** + +Store (id, ts) pairs and trim by age, make markSeen @Synchronized, and use commit()/an atomic update. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Mostly accurate. Two corrections: +> +> 1. The concurrency half is much narrower than the eviction half. SharedPreferences `apply()` updates the in-memory map synchronously, so the race window is microseconds. Also, TerminalController.kt:262's id is usually resolved on the desktop by the `ackRead` just before it, so losing that write rarely causes a re-notify. The dominant real cause is the hash-order eviction once the set passes 500. Each markSeen of k new ids then evicts k arbitrary old ids, and any of them that is still unresolved and under 6 h old gets re-announced. +> +> 2. Better fix: announce() only considers events younger than 6 h (InboxNotifier.kt:79). So store id→seenAt (for example a JSON map in one string pref) and prune entries older than about 6 h plus a margin, rather than using a count cap; nothing still eligible is ever evicted. Make markSeen @Synchronized like the other mutators in the class. commit() is not needed, because apply() already publishes to memory atomically; the missing piece is the lock around the read-modify-write. + +## A49 + +**SSH host-key TOFU pin is saved during key exchange (before auth) and is not tied to the pairing** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: security; kind: risk +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/ssh/SshHostConnection.kt:330` + +**Continuation status (2026-10-04):** the original authentication/pairing-anchor fix remains +in place. The later key-discovery residual is tracked as [A99](#a99): recursive external +`Include` discovery is fixed in `e07071e9`, with actual desktop and full beta 11 source checks +passing; physical included-key pairing remains pending. +Relative `HostKey` values, inaccessible configuration and a key without a readable public +counterpart remain outside discovery. Relative `Include` values are a different case: they are +resolved under the sshd configuration root. No first-use trust or mismatched-key override is added. + +**Evidence** + +The verifier pins whatever key answers first: `pinned == null -> { pin.pin(fp); true }` (line 330). This runs inside `client.connect()`, before `client.authPublickey(...)` (line 348). The catch block (lines 353-357) never undoes the pin, so an SSH server that REJECTS our key still becomes the pin (it is persisted via HostStore.update, ConnectionManager.kt:154-157). Pairing does not anchor the pin: `PairedHost.from` sets `sshHostKeyFingerprint = null` (PairedHost.kt:83), and neither the QR nor the sealed /pair response carries an SSH host key. Every non-Windows host advertises SSH whether or not sshd is running: pairing-service.ts:440 `directSsh = platform !== 'win32'` and line 652 only adds `ssh:false` on Windows. macOS ships with Remote Login off. For such a host the pin stays null and the phone keeps dialing the host's private LAN IP from whatever network it is on, including every 15 minutes from InboxWorker. Once pinned, a mismatch is a hard stop with no relay fallback: ConnectionManager.kt:88-93 rethrows HostKeyChangedException before the relay block. + +**Proposed fix** + +Record the fingerprint in verify() but persist it only after authPublickey succeeds. Anchor the pin in pairing: have the desktop return its SSH host key fingerprints (e.g. from /etc/ssh/ssh_host_*_key.pub) inside the sealed /pair response, and store them in PairedHost.from so the first connect is verified. Do not background-dial a host whose SSH has never authenticated, and allow relay fallback when the pin was never confirmed by a successful auth. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Downgrade to low. Four corrections: +> +> 1. The unpinned window is narrow. On macOS/Linux the pairing QR is only shown while sshd answers on 127.0.0.1:22 (src/shared/pairing-gate.ts; PhonePairPopover.tsx:49; PhoneSection.tsx:95). The app then connects over SSH immediately on the pairing LAN (PairScreen.kt:158 → HostScreen.kt:66 startWatching), so the first pin is normally the real computer's key. The "Remote Login off, so the pin stays null forever" scenario does not happen at pairing. +> +> 2. Pinning during key exchange mirrors OpenSSH's known_hosts behavior. The real defects are: +> - The KDoc says the pin is null "before the first successful connect", but a key exchange whose authentication failed also pins (SshHostConnection.kt:44-47 vs 330/346-357). +> - Pairing cannot anchor the pin: the desktop /pair response (pairing-service.ts:827) and the QR carry no SSH host key. Anchoring needs a desktop change, which would also serve the iOS app. +> +> 3. The more likely user-facing impact is the one listed only as secondary evidence. After a correct pin, a private-IP collision on another network makes Auto mode fail with a "host key changed" error and never tries the relay (ConnectionManager.kt:88-93). +> +> 4. Better fix: +> - On a mismatch, refuse SSH and show the warning, but still fall through to the relay. The relay is independently authenticated (hostKeyB64 / relay host key plus the SAS approval), so this is safe. +> - Persist the pin only after `authPublickey` succeeds. +> - Have the desktop return its SSH host key fingerprints inside the sealed /pair response, and store them in `PairedHost.from`. + +## A50 + +**The only distributable build is a debuggable APK, so Keystore-protected secrets can be pulled over adb/JDWP** + +- Severity: **low**; claimed by auditor: medium; effort: medium; area: security; kind: risk +- Location: `android/app/build.gradle.kts:19` + +**Current private-beta status (2026-10-04):** beta10/code11 from clean e3ce041c is installed on +the intended Pixel with verified signature/non-debuggable metadata, exact APK hash, install +identity and notification grant. Genuine desktop-issued beta9 pairing/relay credentials survive +the same-signer update; actual saved relay reopen/input reaches the scoped PTY. A91/A94 installed +End/recreate/open/End completed-empty flow also passes. Items 1/36 pass and item 49 is Partial, giving 10 Pass / 22 Partial / +32 Pending. This still leaves broader minified worker/QR/cellular-relay/device checks open; the +public distribution/link work is separate from this user's private-beta scope. Receipt: +`.nodeterm/android-beta-build-10/paired-update-result.json`; exact-source CI37194375778 has all +five jobs green. Earlier installation/results below retain their original historical scope. + +**Evidence** + +The release buildType has no signingConfig (lines 19-25). The README's only install path is `./gradlew :app:assembleDebug → app-debug.apk` (android/README.md:32-38). CI uploads `nodeterm-android-debug` (.github/workflows/android.yml:73-80). The desktop sends users to that folder (src/renderer/lib/links.ts:7 `ANDROID_APP_URL = '.../tree/main/android'`). AGP marks debug builds `android:debuggable=true`, which enables `adb shell run-as dev.nodeterm.android` and JDWP attach to the app process. SecureStore's guarantee ('it never leaves the secure hardware') is about the key material only: the key is usable by any code running as the app's UID or inside its process. + +**Proposed fix** + +Ship a signed, non-debuggable release build (configure signingConfig and publish release artifacts), and point the README and desktop link at it. Until then, state in the README that the debug APK exposes the phone's keys to anyone with adb access. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> **Severity is overstated. This is closer to low than medium.** The attack needs physical access to an unlocked phone that has USB debugging on and has accepted the attacker's adb RSA prompt. Someone in that position can already drive the non-debuggable app's own UI (terminal, new session) to run commands on the paired computer. What the debuggable flag adds is exfiltration of durable, reusable credentials: the unrestricted SSH key, and relay impersonation via box secret plus device token. Sideloading also does not require USB debugging; an APK can be installed from the browser, so "often switched on precisely to sideload" is plausible but not required. +> +> **The cited line is right** (build.gradle.kts:19-25). The root cause is more precisely the absence of any signed release artifact or release pipeline. No debug-specific setting is wrong on its own: debug builds are meant to be debuggable. +> +> **The fix should cover three things:** +> - A signed, non-debuggable release (a `signingConfig` fed from CI secrets, `assembleRelease`, artifacts attached to a GitHub Release). +> - Pointing README and `ANDROID_APP_URL` at that release. +> - Until then, a README warning that the debug APK exposes the phone's pairing credentials to anyone with adb access. +> +> **A related, unverified distribution problem:** debug APKs built on CI are signed with a per-runner debug keystore. If that keystore is freshly generated each run, one CI artifact cannot update another in place. Uninstalling first wipes the Keystore and the prefs, which forces a re-pair. + +**Private-beta continuation (2026-10-02):** local signing/verification is implemented in +`android/tools/package-beta.py`; exact takeover-branch pushes (or later opted-in manual CI runs) +version the unsigned AGP release and gate its +provenance on protocol, release and desktop/packaging checks. The tool verifies APK/R8 hashes from +that run's input metadata, rejects the public debug key, requires the expected private certificate, +checks non-debuggable release metadata/alignment/signature, and emits an APK/checksum/metadata set +only after success. Keys and signed APKs stay local: Actions artifacts on a public repo are not +private downloads. Real Android-tool regressions use disposable fixture keys/APKs, not an app build. +The user confirmed the first private beta and its retained signer is prepared in ignored local +state (RSA-3072 PKCS12, restricted directory/files, verified certificate pin distinct from debug). +The actual private minified beta at `fa71cb08` is signed, verified, and was installed and cold-started on +an MI8 (Android 15 / API 35). Installed metadata confirms version code `2`, minSdk `26`, targetSdk +`35`; refused `run-as` confirms the non-debuggable build. With notification permission granted, +the empty Computers screen, Pair button and Settings appear with no crash markers. This addresses +signed-artifact preparation and part of checklist item 5 on that test device. The user identified +the MI8 as the wrong phone, so only the newly installed app and its test UI dump were removed; +its newly authorized SSH key was removed and host `authorized_keys` restored byte-for-byte. +No host had been paired and no SSH connection attempted. The intended Pixel 10 Pro now has the +same signed beta; it runs Android 17 / API 37 with Vanadium WebView `154.0.8037.92.0`, accepted +notifications through the normal dialog, and refuses `run-as`. Manual SSH uses the authorized phone +key with existing keys preserved and a matching Ed25519 host pin, lists 17 real projects and +delivers a harmless sentinel in a controlled temporary test window. The actual code-3 private +update preserved host configuration, SSH key/pin and notification permission, and fixes the one-row +viewport/history failure (`A85`), including font/keyboard resize, Esc and draft input. QR/code +pairing, relay, reconnect/background/answer behavior and full 64-item phone validation leave `A50` +partly open; the user confirms Wi-Fi-off mobile-data WireGuard terminal access. + +**Beta-6 device follow-up (2026-10-02):** the unchanged minified code-7 APK at source +`c4b1f6cf1009f293a658b6331d2ed1ab80aa36c6` passes requirement-audited checklist items 18/19/21/22/24/38/39: +real SSH Unicode rendering, exact 90000-character clipboard/150000-and-450000 size refusals, +silent invalid OSC52 with clipboard preserved, all three keyboard-focus states, and background +process-kill Inbox/back-stack restoration, plus the synthetic shipped-hook lifecycle detailed below. +All 17 sending chips plus four app-mode arrows and software-input/font/rotation SSH survival +complete item 18 on the existing proof. Copy-sheet exact Unicode/Share/modal isolation, +wrapped/OSC8 links and SSH background/detach recovery add partial evidence. Landscape with IME open was 129×1; usable-height +and larger-client Fit remain unverified. The 450000 pre-bridge cap is distinct JVM/JS evidence, +not inferred from a phone toast. Browser/Copy-sheet Open and offer interactions, airplane/outage, +QR/cellular-relay/notification, custom bindings/FPS and remaining variants stay open. +Encrypted paste pairing and a forced relay-only browse of an isolated production desktop +(`58a202be`, real SecretService credential storage) now pass against the live hosted API/relay; +the current `/v1/relay/join` contract is verified beyond interop. Remote access was enabled at +pairing, so no first SAS prompt is expected; QR/scanner, cellular relay, SAS denial/revoke and +remaining node/action behavior remain open. No user-profile credentials were copied into the private home. +The phone then attaches through the relay and sends echo input to an owned real PTY seeded through +production preload/Canvas events; phone New session/folder-picker are not tested. Item 39 now passes: +the shipped managed hook returns actual allow/deny JSON in 17.596/6.571 seconds, and a 45-second +expiry returns empty; the late Approve visibly reports timeout and opens the owned terminal without +false success. The producer is synthetic, with no live Claude CLI/account or requested Bash execution. +Mounted relay single-select one-tap, multi-select Open-session/input and copy-mode answers also +reach the actual synthetic application and its shipped PostToolUse hook/mirror. In the copy-mode +case, the exact owned pane is in mode before PreToolUse; option 3 arrives, mode becomes false, +and the mirror becomes working with its question resolved. Item 41 remains Partial: offscreen/ +released/direct-SSH/target-guard device variants and notification questions remain open. +No live Claude CLI/account was used. At that stage required checks passed all 658 protocol +tests in 63 suites (54 seconds) plus offline app `compileKotlin` (5 seconds). +Only disposable fixture resources were cleaned up and the phone returned to regular Sessions; +scoped cleanup is not a full device/backend revoke check. On 2026-10-03 the user confirms the +final beta-6/code-7 usual manual-SSH terminal "Connects and scrolls smoothly", confirming connection +and smooth scrolling with WireGuard enabled and Wi-Fi off over cellular. Cellular hosted relay remains +untested. Item 20 remains Partial; no runtime change, phone command or new product finding resulted. +Protocol/offline app tasks at that stage passed in 50/1 seconds; current A90 checks are recorded below. +The [64-row record](android.md#what-is-verified-and-how) now has ten Pass, 22 Partial and +32 Pending after item 1 paired-update, focused A90 proof and item 36 Board verification; the earlier beta6 7/20/37 checkpoint is preserved, with named +conditional SKIP variants; `A50` remains partial. Private synthetic +proof is in `.nodeterm/android-beta-build-6/checklist-20261002/`. Recorded prior all-green branch `19da35a2` has all +five jobs green in [run `37140762345`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37140762345), +including A90. Historical beta9 source `4d33a5b5` failed documentation mapping in run `37144282865`; +current installed beta 10 source `e3ce041c` passes all five jobs in run `37194375778`. The next documentation push needs +its own checks/green workflow. +The requirement review promotes item 18 using existing all-key/software-input/font/rotation SSH +survival proof, without new phone work. Viewport/Fit belongs to item 23 and stays Partial; item 1 +now passes desktop-issued beta9 pairing/relay survival across the code10→11 update. +The user resumed Pixel release checks on Oct4; full release, cellular hosted relay and live-Claude +verification remain open. No new product finding is added. +QA-driver coordinates/side-Back and Compose class assumptions are not product findings. No +production/host contract changed and no new audit finding is added. + +**Historical prepared beta-7 update (2026-10-03), unused:** private `0.1.0-beta.7` / code `8` uses +source `b53610deb3843b59fa6a1bed5bdc5f36da0f5146` and the retained signer. The local AGP build +took 47 seconds; R8 and packaging passed. APK SHA-256: +`5141c6484b422b236a98213731076be621c4d14a55f47c79bfd989fb23609e6a`. +Ignored proof/artifacts are in `.nodeterm/android-beta-build-7/` and `.nodeterm/android-beta-7/`. +It was never installed and is superseded by beta 8 / code 9 below. Debug migration stays +conditional SKIP on this working Pixel. Historical beta-6 proof at `c4b1f6cf` and the +seven Pass / 20 Partial / 37 Pending tally remain unchanged. Preparation adds no runtime fix, +finding, phone work or device pass; `A50` stays partial. + +**Historical beta-8 update (2026-10-03), superseded by installed beta 9:** A90's code-9 APK at clean +`b88d141528c1051964da07faf22cc7fa923c4846` passes the offline 49-second AGP release, R8 keeps, +retained-signer packaging/provenance and independent SDK 36/37 artifact checks. SHA-256: +`d373ad5c1790f714cb4464ad4a0a38c5ba9ab68e35103e54cf3aef5ce53081ce`. +The exact intended Pixel 10 Pro / Android 17 received a 28.05-second same-signer `adb install -r`. +Pulled pre/post APKs match known beta 6 / beta 8 and the SDK-37 public signer check matches the +retained pin. Post-install metadata is code 9/name beta 8/non-debuggable; `firstInstallTime` and +granted `POST_NOTIFICATIONS` are preserved. The app starts and its existing manual-SSH host row +remains in All computers. Opening that exact host reconnects over SSH and lists real driven +projects. The screenshot shows New terminal and own-app UI XML confirms the FAB is enabled/clickable; +this is not a TalkBack check. No existing pane was touched and no terminal was created or ended. +These facts establish manual-host configuration/reconnect/browse survival, not A90 creation or +desktop-issued pairing/relay credential survival. +Private installation proof is `.nodeterm/android-beta-build-8/device-install-20261003/receipt.json` +plus package/APK/certificate checks, `sessions.png` and `ui-sessions-ready.xml`. After the hike, pair on current beta 9 / +code 10 using desktop-issued JSON or QR, then verify pairing/relay credential survival across a later +same-signer higher-code update without downgrading or uninstalling (item 1). Historical beta-6 +physical proof and the seven Pass / 20 Partial / 37 Pending ledger remain unchanged; `A50` stays partial. + +## A51 + +**allowBackup=false does not stop device-to-device migration at targetSdk 35: hosts, pins and deviceId are cloned** + +- Severity: **low**; claimed by auditor: low; effort: small; area: security; kind: gap +- Location: `android/app/src/main/AndroidManifest.xml:13` + +**Evidence** + +The manifest sets only `android:allowBackup="false"`, with no `android:dataExtractionRules`, and build.gradle.kts:14 targets SDK 35. Android 12's behavior changes state that for apps targeting 31+, allowBackup=false disables cloud backup but not device-to-device transfer; only a `` exclusion in dataExtractionRules does. `nodeterm.hosts` (every PairedHost, its SSH pin and relay block) and the phone's `deviceId` (HostStore.kt:66-69) are plain SharedPreferences and move to the new phone. `nodeterm.secure` ciphertext moves too but cannot be decrypted there, so SecureStore regenerates the identity. The backend keys the device row on that deviceId: pairing-service.ts:921 says the mint 'upserts on it'. + +**Proposed fix** + +Add android:dataExtractionRules with `` and `` excluding the `nodeterm.secure` and `nodeterm.hosts` shared_prefs (or excluding everything). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Mostly right. Refinements: +> +> 1. **Revoke only happens on Pro desktops.** `revokeRelayDevice` returns 'skipped' when the desktop holds no entitlement (`pairing-service.ts:156`: `if (!entitlement || !relayDeviceId || !apiBase) return 'skipped'`). So the "Remove revokes both phones" effect only happens on an entitled (Pro) desktop. +> 2. **A second, likely effect on free desktops (backend not in the repo, so not verified).** The mint comment at `pairing-service.ts:84-87` says the backend's free-tier re-registration demands `priorDeviceToken` for an existing deviceId, and without it returns 403, leaving a "silent LAN-only pairing". The migrated phone re-registers an existing deviceId but cannot decrypt its prior token (`PairScreen.kt:140` returns null). Unless the backend's same-desktop allowance applies, its relay leg may be refused. On a Windows desktop that refusal fails the pairing outright (502). +> 3. **Wording of the fix.** Exclusion paths for the sharedpref domain need the `.xml` suffix: `` and `path="nodeterm.hosts.xml"`, under both `` and ``, referenced with `android:dataExtractionRules="@xml/data_extraction_rules"`. +> 4. **A sturdier fix.** Keep `deviceId` inside SecureStore, or re-mint it whenever the box secret had to be regenerated. That way the relay id always changes together with the keys it belongs to, whatever the backup rules say. +> +> Severity "low" is reasonable. It only bites after an Android 12+ phone migration, and the host list is recoverable by re-pairing. + +## A52 + +**Approval and finish notifications put command text and the agent's last message on the lock screen** + +- Severity: **low**; claimed by auditor: low; effort: small; area: security; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:108` + +**Evidence** + +`.setContentText(ev.title + (ev.detail?.let { " — $it" } ?: ""))` plus BigTextStyle with the detail (lines 108-109). There is no setVisibility, setPublicVersion or channel lockscreenVisibility, so the default VISIBILITY_PRIVATE applies, which shows full content on a secure lock screen under Android's default 'show sensitive content' setting. For approvals, `detail` is the tool summary: the first line of the Bash command, the file path with the diff size, or the fetched URL (agent-status-mirror.ts:1143-1198). For DONE events it is the agent's last message line (agent-status-mirror.ts:1691). + +**Proposed fix** + +Keep `detail` out of the notification by default: for example, set VISIBILITY_PRIVATE with a setPublicVersion that shows only 'Needs you — ' / '', and add an opt-in 'Show details on lock screen' setting. Or use VISIBILITY_SECRET for the attention channel. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The cited line is right: line 108, with line 109 carrying the BigTextStyle. +> +> The first proposed fix would not work. VISIBILITY_PRIVATE is already the default, and Android shows the public version only when the user has turned on "hide sensitive content" for the lock screen. Under the default setting, a PRIVATE notification is still shown in full whether or not a public version exists. So "VISIBILITY_PRIVATE plus setPublicVersion" does not hide the detail in the case the finding describes. +> +> Fixes that would work: +> - Use VISIBILITY_SECRET, on the notification or as the channel's lockscreenVisibility. +> - Or leave `detail` out of both contentText and BigTextStyle by default, and put it behind an opt-in setting (for example "show details in notifications"). +> +> The setPublicVersion that shows only "Needs you — " is worth adding alongside either fix. It only helps users who already hide sensitive content, so on its own it does nothing for the default case. +> +> The finding should also say that the same detail already reaches the iOS phone through the APNs push body (src/core/push-notify.ts:361). This is a difference in platform defaults, not an Android-only leak. + +## A53 + +**OSC 52 handler has no size cap (the desktop caps at 1,000,000) and setPrimaryClip is unguarded** + +- Severity: **low**; claimed by auditor: low; effort: small; area: security; kind: bug +- Location: `android/app/src/main/assets/terminal/terminal.js:55` + +**Evidence** + +Android: `var payload = idx >= 0 ? data.slice(idx + 1) : data; if (payload && payload !== '?') bridge.onCopy(payload)`, with no length limit and no `;` required. TerminalController.kt:153-161 decodes it and calls `cm.setPrimaryClip(...)` inside `main.post {}` with no try/catch. Desktop: src/renderer/terminal/osc52.ts `const MAX_BASE64 = 1_000_000 ... if (i < 0) return null ... if (!payload || payload === '?' || payload.length > MAX_BASE64) return null`, and it decodes with `fatal: true`. The vendored xterm.js accepts OSC payloads up to `PAYLOAD_LIMIT=1e7`. + +**Proposed fix** + +Mirror parseOsc52: require `;`, cap the base64 at the desktop's limit or lower, and decode strictly. Wrap setPrimaryClip in try/catch and toast on failure. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is correct, but the proposed fix is partly insufficient. Capping at the desktop's `MAX_BASE64 = 1_000_000` would NOT prevent the crash: +> - tmux 3.4 forwards a 986,675-character base64 OSC 52 (measured), which is under 1,000,000 characters. +> - That decodes to about 740 KB of text, which ClipData parcels as UTF-16 at about 1.5 MB, still over the binder limit. +> +> The fix that actually matters: +> 1. Wrap `setPrimaryClip` in try/catch and show a "copy too large" toast on failure. +> 2. Add a much lower cap in `terminal.js` and/or `onCopy`, for example a few hundred KB of base64 or about 100k characters of decoded text, to stay well under the shared 1 MB binder buffer. +> +> Requiring `;` and decoding with a strict UTF-8 decoder (`CharsetDecoder` with `CodingErrorAction.REPORT`) are parity niceties only; neither prevents the crash. +> +> The severity of low is defensible. Medium is also arguable, because remote pane output can crash the app, but only through a deliberately oversized sequence or a very large copy-mode selection. + +## A54 + +**PairingClient trusts an unbounded Content-Length / EOF body from the pairing endpoint** + +- Severity: **low**; claimed by auditor: low; effort: small; area: security; kind: bug +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/pairing/PairingClient.kt:253` + +**Evidence** + +`val buf = ByteArray(length)` uses a Content-Length taken from `toIntOrNull()`, with no upper bound or sign check (lines 247-254). Without a Content-Length it reads with `input.readBytes()` until EOF (line 263). The only limit is the 64 KiB header cap and a per-read soTimeout. PairScreen catches only `Exception` (PairScreen.kt:159). The endpoint is whatever `host:pairPort` the payload names, and a `nodeterm://pair` link from any web page can supply it (the user still has to tap Pair). + +**Proposed fix** + +Reject a negative Content-Length or one above a small cap (the real response is well under 64 KiB), bound the EOF read the same way, and add an overall deadline for the exchange. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Line numbers: the Content-Length parse is at PairingClient.kt:132-137 (`toIntOrNull()` at line 135), `ByteArray(length)` is at line 139, and the EOF `input.readBytes()` is at line 148. The finding cites 247-254, 253 and 263, which do not exist in this 152-line file. +> +> The negative-value case does not crash. NegativeArraySizeException is a RuntimeException, so PairScreen.kt:159's `catch (e: Exception)` catches it. The screen then shows the exception's bare message (e.g. "-1") as the error instead of a sentence. Only the huge-positive and unbounded-EOF cases (OutOfMemoryError) crash the app. +> +> Proposed fix, confirmed as sound: +> - Reject a negative Content-Length, and any value above a small cap such as 64 KiB, with a PairingException. +> - Cap the no-length EOF read at the same size. +> - Bound the whole exchange with an overall deadline, e.g. `withTimeout`, and close the socket on cancellation so the blocking read unblocks. + +## A55 + +**Inbox and Usage are per computer; iOS merges them across all paired computers** + +- Severity: **low**; claimed by auditor: medium; effort: medium; area: parity; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt:64` + +**Evidence** + +docs/mobile-usage-inbox.md:130: "**Agents tab (feed)**: merged events across connections, newest first". :124: "**Usages tab**: one section per paired connection that reports `usage` (header: host name + relative `updatedAt`)". Android's `InboxTab(nav, hostId, session, snapshot)` (InboxTab.kt:64) and `UsageTab(snapshot)` each render one computer inside HostScreen's tabs. The only routes are Hosts/PairHost/Settings/Host/Terminal (MainActivity.kt:28-33), with no cross-computer inbox. HostsScreen shows no needs-you count per computer. + +**Proposed fix** + +Add a top-level Inbox/Usage screen that merges every paired computer's snapshot, labels each card with its computer, and adds a needs-you badge to each computer row. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Refinements only; nothing in the claim is wrong. UsageTab is defined in the same file (InboxTab.kt:213), not in its own file. The Route list is at MainActivity.kt:28-34. HostScreen already computes a per-computer needs-you count (HostScreen.kt:70). The cheapest part of the fix is to lift that `count { it.actionable }` onto each HostsScreen row, using the session's cached snapshot. The merged Agents feed and Usages view also need each card and section to carry its hostId. The feed's Open, Approve and Answer actions have to call the right computer's `session.ensureConnected()`, because QuickActions is per-session today (InboxTab.kt:103-115). + + +**Continuation status (2026-10-04):** the merged Inbox/Usage and computer labels were fixed in +`71b592a`/`0772cbf`; the original evidence above describes the pre-fix screen. A remaining +freshness gap persisted in the beta 10 source: opening All computers requested one refresh, but +only individual host/terminal screens owned the live eight-second watcher. [A97](#a97) now adds +STARTED-only per-host watching and serialized listing/publication in `8443e71e`. Full beta 11 +source checks pass; new physical multi-computer results are pending. The existing item 60 +ledger is unchanged. + +## A56 + +**Current follow-up:** A105 implements request-owned remembered rules through hook JSON; the original blind-digit proposal below remains unsafe. + +**Approval cards lack iOS's "Always allow" answer** + +- Severity: **low**; claimed by auditor: low; effort: small; area: parity; kind: gap +- Location: `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/QuickActions.kt:23` + +**Evidence** + +docs/hook-reply-approvals.md:38-40, phone answerer: "`InboxApproval` writes it over the connection when the approval event carries `pendingId`; else falls back to send-keys. Digit `2`/\"Always allow\" keeps using send-keys in v1". Android's `answerApproval(conn, event, allow: Boolean)` (QuickActions.kt:23) supports only allow ("1" or the ticket) and deny (Esc). InboxTab offers only Approve / Deny / Open (InboxTab.kt:107-114). + +**Proposed fix** + +Add an "Always allow" action for Claude approvals. It re-checks that the node is still blocked, then sends `2` with send-keys; for a held ticket, open the session instead, since the prompt is not on screen. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line references are right. Two corrections: +> +> 1. **Evidence.** The iOS behaviour rests only on docs/hook-reply-approvals.md:39-40. docs/mobile-usage-inbox.md:135-140, the v1 phone spec, defines only Approve and Deny. +> +> 2. **Impact and fix.** `hookReplyApprovals` is on by default (src/shared/types.ts:1986), so most Claude approvals are held tickets. A keyed "Always allow" only works for unticketed ones. +> - For a held ticket, "open the session instead" does not put the choice in front of the user right away. The prompt is not painted until the hook times out (`NODETERM_PERM_WAIT_SECS`, default 45 s, per docs/hook-reply-approvals.md:24-30). Until then the opened pane shows no prompt to pick `2` from. +> - Better fix: add an "Always allow" button only when `event.agentId == "claude"` and `event.pendingId == null`. Re-check that the node is still BLOCKED with `stillWaiting`, then `sendKeys(nodeId, "2")`. +> - For ticketed approvals, either hide the button, or open the session with a note that the prompt appears after the hold times out. +> - Also add the gap to docs/android.md "Known gaps" if it is not built. + +## A57 + +**Current follow-up:** A106 extends the display fix to complete held single/multi question answers through hook JSON. Legacy unheld multi-select still opens the session. + +**Multi-select AskUserQuestion cards fall back to "Open session"** + +- Severity: **low**; claimed by auditor: low; effort: small; area: parity; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt:118` + +**Evidence** + +src/core/agent-status-mirror.ts:384-386: `multiSelect` "Rides the pipeline so the phone renders multi-select chips". src/core/push-notify.ts:61-62: "Rides the `nt` block so the phone renders multi-select". Android shows option chips only when `ev.options.isNotEmpty() && !ev.multiSelect` (InboxTab.kt:118), and `QuickActions.answerQuestion` returns OPEN_SESSION when `event.multiSelect` (QuickActions.kt:43). + +**Proposed fix** + +Render toggleable chips for multi-select questions and send the chosen digits followed by the picker's submit key, re-checking that the node is still waiting before each send. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Line and root cause are right: InboxTab.kt:118 and QuickActions.kt:43 both deliberately route multi-select questions to "Open session", in line with the fail-safe policy at QuickActions.kt:17-18. Two corrections to the wider claim. +> +> 1. The gap that can actually be shown is on the display side. Android does not show a multi-select question's options at all; the else branch at InboxTab.kt:124-126 renders only "Open session". The desktop comments (agent-status-mirror.ts:384-386, push-notify.ts:61-63) say the phone should render multi-select chips. Whether iOS also answers these questions cannot be verified from this repo. +> +> 2. The proposed answer path is speculative. No keystroke sequence for Claude Code's multi-select picker (toggle key, submit key) is measured or documented anywhere here. A safer fix has two steps: +> - Now: render the options read-only (numbered, non-interactive) next to "Open session", so the card shows what is being asked. +> - Later: add answer-from-Inbox only after measuring the picker's real key semantics on a live CLI, and keep the stillWaiting re-check before sending. + +## A58 + +**Usage and feed cards omit iOS's pace line and the context indicator on event cards** + +- Severity: **low**; claimed by auditor: low; effort: small; area: parity; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/InboxTab.kt:246` + +**Evidence** + +docs/mobile-usage-inbox.md:128-129: "Pace line: compare `usedPercent` vs elapsed fraction of the window (`windowMinutes` else 300/10080 by kind) → \"5h usage pace slower/faster\"". :131-132: "Cards show the node's `contextPercent` ring when known". Android's `UsageBar` (InboxTab.kt:246-273) shows only the percentage and reset time, never using `windowMinutes`. `EventCard` (InboxTab.kt:163-208) shows no context % (only the live working cards do). + +**Proposed fix** + +Compute the pace from `resetsAt` and `windowMinutes`, using the 300/10080-minute defaults by kind, and show `inbox.nodes[nodeId].contextPercent` on approval, question and done cards. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line range for EventCard is slightly off. It is InboxTab.kt:162-206, not 163-208. UsageBar is 245-272 and the reset text is at line 263. The fix should compute the pace only when `resetsAt` is non-null: elapsed = 1 - (resetsAt - now) / (windowMinutes ?: (if kind == "session" then 300 else 10080)) / 60000. Compare that with usedPercent/100 to get "pace slower/faster". Also note that `windowMinutes` is null in some desktop mirror payloads (src/core/agent-status-mirror.test.ts:1584), so the per-kind defaults are required. The context value should come from `snapshot.status?.inbox?.nodes?.get(ev.nodeId)?.contextPercent`, which means passing it into EventCard. + +## A59 + +**No built-in dictation (iOS has on-device Whisper plus a Cloud engine)** + +- Severity: **low**; claimed by auditor: low; effort: small; area: parity; kind: gap +- Location: `android/app/src/main/AndroidManifest.xml:4` + +**Evidence** + +src/core/speech/whisper-models.ts:8-9: "The fences here are lessons already paid for on iOS: a download streams to a per-download `.part.`". src/core/speech/cloud-speech.ts:4-6: "the SAME wire contract the iOS Cloud engine speaks (multipart WAV + locale, Bearer license token)". CLAUDE.md:5124: "**Mobile** keeps its own list, tracked separately as issue #591" (speech languages). Android has no speech code (a grep for speech/dictat/RECORD_AUDIO under android/ finds nothing), and the manifest declares only INTERNET, ACCESS_NETWORK_STATE, POST_NOTIFICATIONS and CAMERA (AndroidManifest.xml:4-8). The terminal input bar is a Compose `OutlinedTextField` (TerminalScreen.kt:124), so the keyboard's own voice typing still works. + +**Proposed fix** + +Add a mic button to the input bar using Android's SpeechRecognizer (or on-device whisper.cpp) that fills the draft and never auto-submits. Send the same multipart request to `/v1/transcribe` once it exists. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two corrections. +> +> 1. The Cloud half of the parity claim does not matter yet. /v1/transcribe does not exist. CLAUDE.md:5101 says "not built yet", and cloud-speech.ts:6-7 says every call "maps 404 to a friendly 'not available yet'". So the iOS Cloud engine cannot transcribe anything today either. The real, current gap is on-device dictation plus a language choice. +> +> 2. The gap is also missing from docs/android.md "Known gaps" (lines 88-105). Either implement dictation or list it there. Citing TerminalScreen.kt:123-139 (the input Row) is more precise than manifest line 4. +> +> The proposed fix is reasonable. Android's SpeechRecognizer needs RECORD_AUDIO plus a runtime permission prompt. It should fill the draft only and never call controller.submit, which matches the desktop rule that nothing auto-submits. + +## A60 + +**Interop fixture needs the Electron binary, which is downloaded at test time inside the 20 s ready window, despite the workflow saying 'not Electron'** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: bug +- Location: `android/protocol/src/test/interop/host-fixture.ts:22` + +**Evidence** + +The workflow comment at android.yml:39-42 says the fixture needs 'tweetnacl, ws and esbuild — not Electron ... hence no scripts', and InteropHarness.kt:72 bundles with `--external:electron`. The bundle still has three top-level `require("electron")` calls, from host-service.ts (`import { app, ipcMain } from 'electron'`, host-service.ts:28), host-identity.ts and host-canvas-hub.ts. I reproduced this with esbuild in the scratchpad: the bundle's lines 2330, 3565 and 4132 are `var import_electron… = require("electron")`, and they run when the module loads. Electron 42.11.3 has no postinstall. Its index.js ends in `module.exports = getElectronPath()`. When path.txt is missing, that function calls `downloadElectron()`, which does `spawnSync(process.execPath, [install.js])`, a synchronous download of about 110 MB from GitHub releases that extracts to 312 MB. If the download fails, it throws 'Electron failed to install correctly'. I simulated a clean node_modules/electron with an unreachable mirror, and `require` threw after 334 ms. On a fresh runner nothing downloads Electron beforehand. So the first `InteropHarness.start` pays for the download before the fixture can print its ready line, and the harness waits only `h.await(20_000)` for that line (InteropHarness.kt:93). A slow or failed download makes every interop test fail with 'fixture event not seen within 20000ms'. Local corroboration: node_modules/electron/path.txt and ~/.cache/electron are both dated 07:13, 18 minutes after `npm install` (06:54) and just before the protocol commit. That fits the first local interop run having done the download. + +**Proposed fix** + +Stop the fixture from loading the real electron package. For example, alias `electron` to a tiny stub exporting no-op `app`/`ipcMain` (`--alias:electron=./android/protocol/src/test/interop/electron-stub.ts`), or have the harness set `ELECTRON_OVERRIDE_DIST_PATH`, which makes index.js return a path without downloading. Then correct the workflow comment. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> 1. **Severity is lower than claimed; I'd call it low-to-medium.** On a GitHub-hosted runner, a GitHub-releases download of ~125 MB plus unzip usually finishes well inside 20 s. The normal outcome is a hidden 125 MB download (312 MB on disk) that adds a few seconds. Failures only happen when that download is slow or fails. Then both interop classes fail after 20 s: RelayInteropTest and PairingInteropTest (one `InteropHarness.start` each). +> - An aggravating detail: when `h.await(20_000)` throws inside `start()`, the handle was never returned, so `close()` never runs. The node process is orphaned and keeps downloading, and the next test can start a second, concurrent download into the same dist/. +> +> 2. **The local timestamp corroboration does not hold up.** node_modules/electron/path.txt is 07:13:04, but android/protocol/build/interop/ was created at 07:16:38, 3.5 minutes later. The download therefore happened before the first bundle recorded in that directory, and the timestamps do not show that the harness triggered it. +> +> 3. **The proposed stub is incomplete.** Seven src/main/remote files import from electron, including host-identity.ts (`app, safeStorage`) and approved-devices.ts / peer-identity.ts (`app`), so a stub must also export `safeStorage`. +> - Today, plain-node `require("electron")` returns a path string, so every member is already undefined at runtime. An empty-object stub is therefore behaviour-preserving for the current tests. +> - The simplest fix is `pb.environment()["ELECTRON_OVERRIDE_DIST_PATH"] = ` in InteropHarness.start. The cleaner fix is esbuild `--alias:electron=`, which also removes the need for the electron package entirely. +> - Either way, correct the comment at android.yml:39-40. + +## A61 + +**Following CONTRIBUTING / android/README (`npm ci --ignore-scripts`) wipes a desktop developer's patched node_modules** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: bug +- Location: `CONTRIBUTING.md:111` + +**Evidence** + +CONTRIBUTING.md:107-111 is aimed at desktop contributors who change host-service.ts, the blob, the mirror and similar. It tells them to run `./gradlew -p protocol test` (from `android/`, after `npm ci --ignore-scripts`). android/README.md:48 says the same ('Run `npm ci --ignore-scripts` at the repo root first'). `npm ci` always deletes node_modules, and `--ignore-scripts` skips the root postinstall `node scripts/patch-node-pty.mjs && electron-rebuild -f -w node-pty,smart-whisper` and node-pty's own install script (`node scripts/prebuild.js || node-gyp rebuild`). After that, src/main/node-pty-patch.test.ts, which reads node_modules/node-pty/src/unix/pty.cc and src/win/conpty.cc, goes red in the contributor's `npm test`. On Linux node-pty ships no prebuild (its prebuilds/ folder only has darwin-*/win32-*), so `npm run dev` has no pty.node. On macOS it falls back to the unpatched ptmx-leaking prebuild that CLAUDE.md calls 'not optional' to patch. The harness itself only checks that node_modules/.bin/esbuild, node_modules/ws and node_modules/tweetnacl exist (InteropHarness.kt:81-85), so any ordinary `npm install` / `npm ci` already works. + +**Proposed fix** + +Say that a normal `npm install` is enough and that `--ignore-scripts` is only for a machine without the native toolchain (as CI uses it). Warn that it replaces an existing node_modules. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is accurate on the facts, the line numbers and the proposed fix. Severity is at the low end of medium, and arguably low: this is a documentation-only problem that affects developers, not the app or its users, and the test's own failure message names the recovery (`npm run rebuild`). +> +> Recommended fix, in two parts: +> 1. **Scope the flag in both docs.** In CONTRIBUTING.md:110-111 and android/README.md:48, say that an existing `npm install` is enough. `--ignore-scripts` is only for a machine without the native toolchain, as CI uses it (android.yml:39-42). +> 2. **Add a warning.** `npm ci` deletes node_modules, so running it with `--ignore-scripts` over a working desktop checkout leaves node-pty unpatched and unbuilt until `npm install` or `npm run rebuild` is run again. + +## A62 + +**SSH transport tests fail, not skip, on macOS: the gate checks only that /usr/bin/script exists, then runs util-linux-only flags** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: bug +- Location: `android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt:54` + +**Evidence** + +The gate at lines 54-55 is `tmuxAvailable() = ... tmux -V ... && File("/usr/bin/script").exists()`. Every pty-requesting exec then runs `listOf("script", "-qfec", "stty cols $cols rows $lines ...; $command", "/dev/null")` (lines 84-85). `-c ` (and `-f`, `-e` in this form) is util-linux syntax. macOS /usr/bin/script is the BSD one (`script [-aeFkqr] [-t time] [file [command ...]]`), which has no `-c` and exits with a usage error. On a Mac with Homebrew tmux on PATH, which is the normal nodeterm developer setup, the gate passes and the pty-backed tests fail: the pane process dies immediately, and `Sink.waitFor` throws "'…' never appeared". This affects the attach and cold-start tests. CONTRIBUTING.md:110-111 tells every contributor to run exactly this suite. + +**Proposed fix** + +Gate on util-linux `script` (for example, require `script --version` to report util-linux), or `assumeTrue(os.name == Linux)`, with a skip reason. Alternatively, allocate the pty in the MINA command without `script`. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two corrections to the finding: +> - **Root cause and scope.** On macOS the first failure is not `Sink.waitFor` in the pty tests. `@BeforeAll` fails earlier: the private `TMUX_TMPDIR` is made by `Files.createTempDirectory("nt-ssh")` under `/var/folders/…/T/` (line 126). tmux resolves that to `/private/var/…`, so the socket path comes to about 110 characters, over macOS's 103. `tmux new-session` fails with "File name too long", and `assertEquals(0, code, out)` at line 187 throws. That fails all 8 tests in the class, not just attach and cold start. The BSD-`script` incompatibility at line 85 is real but only shows up after that is fixed. +> - **Citation.** The CONTRIBUTING.md reference is lines 106-109. +> +> The fix needs both parts: +> 1. **Short socket directory.** Create a short, realpath'd `TMUX_TMPDIR`, the way `src/core/tmux-test-socket.ts` `makeTmuxTmpdir` does: prefer `/tmp` resolved to `/private/tmp`, and check `/tmux-/node-terminal` fits in 103 characters. +> 2. **`script` handling.** Either gate on util-linux `script` with a skip reason (for example, `script --version` reports util-linux, or `assumeTrue(os.name == Linux)`), or branch to BSD syntax: `script -q /dev/null /bin/sh -c '…'`. + +## A63 + +**Android workflow path filters miss files that change the tested wire behavior, contrary to CLAUDE.md** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: gap +- Location: `.github/workflows/android.yml:7` + +**Evidence** + +The triggers cover only `android/**`, `src/main/remote/**`, `src/main/pairing-*.ts` and the workflow file itself (lines 6-17). ci.yml runs no Gradle. CLAUDE.md:5390-5396 says a change to 'a host-service.ts verb, the projects.list blob, the pairing payload, the mirror file, or the SSH-visible file contracts ... the Android workflow (.github/workflows/android.yml) runs on those paths'. Several of those producers are outside the filter. (1) host-service.ts validates the phone's params with `isValidPendingId` from src/core/agents/pending-approvals.ts (host-service.ts:37; this is exactly what the interop `approvals.answer` test exercises), with `parseCardLabelEdit` from src/core/project-kanban-write.ts (:36), and with `TITLE_MAX` from src/core/project-node-append.ts (:35). All three are in the fixture bundle; I listed its esbuild metafile inputs. (2) The projects.list blob is built by `listProjectsOutput` in src/main/index.ts:632, whose sync comment at :621-623 names only NodetermProjects.swift, not ProjectsParser.kt. The new approvals.answer/inbox.ack wiring is also in src/main/index.ts (about :4019). (3) The mirror is src/core/agent-status-mirror.ts, and acks are consumed by src/core/ack-sweep.ts. (4) The URL-form pairing QR that PairingPayload parses comes from src/shared/pair-qr.ts. (5) package-lock.json pins the ws/tweetnacl/esbuild/electron the fixture runs. None of these triggers the workflow. + +**Proposed fix** + +Add the fixture's transitive sources (at least src/core/agents/pending-approvals.ts, src/core/project-kanban-write.ts, src/core/project-node-append.ts, src/shared/**), src/main/index.ts, src/core/agent-status-mirror.ts, src/core/ack-sweep.ts, src/shared/pair-qr.ts and package-lock.json to both path lists. Or drop the path filter, since the protocol job is cheap. Also name ProjectsParser.kt in the index.ts marker comment. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause is right for the fixture's transitive sources, but the list is incomplete and part of the fix does nothing. The esbuild metafile shows 29 of the 43 bundled repo sources outside the filter, including src/main/windows-ssh-keys.ts (via pairing-service), src/core/license.ts, src/core/fs-ops.ts, src/core/platform.ts and many files under src/shared/. So the path list should cover at least `src/core/**`, `src/shared/**`, `src/main/windows-ssh-keys.ts` (or `src/main/*.ts`) and `package-lock.json`. The simplest fix is still to drop the path filter for the cheap protocol job. +> +> Adding src/main/index.ts, src/core/agent-status-mirror.ts, src/core/ack-sweep.ts and src/shared/pair-qr.ts to the trigger would NOT make drift there fail anything. The fixture serves a hand-written blob and mirror (host-fixture.ts:140-165, :182 `listProjects: async () => blob`), and none of those producers is in the bundle. Closing that gap needs one of two things: +> - tests that build the blob, mirror and QR from the real producers, for example by extracting the blob assembly out of index.ts into a core module the fixture can import; +> - or CLAUDE.md:5392-5396 stops claiming the workflow covers "the projects.list blob ... the mirror file". +> +> The index.ts marker comment (:621-623) should also name ProjectsParser.kt. Line references: the blob builder is at index.ts:633 (not 632), and the inbox wiring is at index.ts:4024-4025. + +## A64 + +**Docs say the protocol tests check the mirror, the projects.list blob and the ~/.nodeterm files against desktop code, but those shapes are hand-copied in the tests** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: gap +- Location: `CONTRIBUTING.md:107` + +**Evidence** + +CONTRIBUTING.md:107-111 says a change to 'the `projects.list` blob, ..., the agent-status mirror, or the `~/.nodeterm/{pending,acks,relay.json}` files' is caught because `./gradlew -p protocol test` 'runs it against this repo's own host code'. CLAUDE.md:5392-5396 makes the same promise. In the relay fixture, the blob, including the whole mirror JSON with `inbox.events`/`pendingId`, is a literal written by hand (host-fixture.ts:129-165 and `listProjects: async () => blob` at :180). `answerPermission`, `ackRead`, `registerNode` and the three kanban writers are fakes that only emit events (:181-204). SshTransportTest.kt:168-184 hand-writes workspace.json and agent-status.json, and tests pending/acks by writing files itself (:283-294). No test runs `listProjectsOutput`, `agent-status-mirror.ts`, `ack-sweep.ts` or the index.ts `inbox` wiring. Only connectHostSession's RPC routing/validation and createPairingService are real. + +**Proposed fix** + +Either narrow the docs to what is actually exercised (relay framing/handshake and host-service validation, pairing service) and name the hand-copied contracts, or generate the fixture's blob and mirror from the real writers (for example, build the mirror with AgentStatusMirror and the blob with the same assembly listProjectsOutput uses). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The CONTRIBUTING.md:107-111 overclaim is real, and the fixture and test citations are correct (host-fixture.ts:129-165, :182, :183-205; SshTransportTest.kt:152-189 and :283-296). One part of the finding is wrong: CLAUDE.md:5388-5390 does not overclaim test coverage. It accurately names connectHostSession, createPairingService and the SSH server, and it tells contributors to update the hand-maintained fixture. CLAUDE.md's false statement is at :5395-5396, "the Android workflow ... runs on those paths". android.yml:5-17 only filters on android/**, src/main/remote/** and src/main/pairing-*.ts. So changes to src/main/index.ts (listProjectsOutput), src/core/agent-status-mirror.ts, src/core/ack-sweep.ts and src/core/agents/pending-approvals.ts do not trigger Android CI at all. docs/android.md:69-70, which lists `projects.list` as verified against the real host, is also overstated. Better fix, either or both of: +> (a) Narrow CONTRIBUTING.md, CLAUDE.md and docs/android.md to what is real: relay framing, handshake and host-service verb routing/validation, pairing, crypto, and the SSH scripts against real tmux. Name the hand-copied contracts (blob, mirror, v3 index, pending/acks, relay.json) and add the missing source paths to android.yml's `paths` filter. +> (b) Generate the fixture's mirror with the real AgentStatusMirror. Export the split markers and the blob assembly from a core module so the fixture and index.ts share one definition. In the SSH test, drive ack-sweep and pending-approvals against the files the Kotlin client writes. + +**Current coverage update (2026-10-05):** actual producers now cover relay projects/mirror, +selected Server-profile publications, SSH action/managed writers and local/remote acknowledgement +consumers (A108/A111/A119). The newer relay-advertisement tests additionally run the actual +account-level writer/remover and Kotlin parser through private SSH, with every field, replacement, +removal and profile isolation checked. All 75 affected methods pass; four behavior mutants are +assertion-caught, with passing control/restored runs. The producer has a temporary OS-home adapter +during initialization; full Server boot/hooks/account probes, driven-slice producer parity and +standing-host/token/adoption/SAS/revoke behavior remain outside this proof. Legacy/adversarial +fixtures remain deliberately hand-written. Private evidence: +`.nodeterm/android-beta-build-16/desktop-links-and-advertisement-receipt/a64/`. +No production contract/APK or physical-ledger change follows. + +## A65 + +**User-facing docs and desktop UI present the Android app as working, but it has never been built by AGP or run on a device, and no device checklist exists** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: ci-docs; kind: gap +- Location: `android/README.md:11` +- Note: PARTLY OBSOLETE: the premise "never built by AGP" is wrong. CI run 36109984730 built the debug APK with AGP on 2026-09-25. The "never run on a device" half still holds. + +**Evidence** + +android/README.md:11-26 marks 15 features ✓ (terminal with OSC 52 copy, swipe-to-scroll, WorkManager notifications, and more) with no caveat. README.md:49 and :103 now say 'phone companions for iOS and Android' and 'scan with the nodeterm app on iPhone or Android'. The desktop UI now links 'nodeterm for Android ↗' (PhonePairPopover.tsx:196-201, PhoneSection.tsx:213-218), and its pairing copy was generalized to 'the nodeterm phone app'. docs/android.md's 'What is verified' section (lines 62-86) only says the app module 'is built by CI ... it has no instrumented tests yet'. On this branch the workflow is new (added in 2f58918) and has never run. The app Kotlin was only type-checked against stubs because Google Maven was unreachable, so aapt2, the manifest merger, D8 and any device run are all unverified. CLAUDE.md requires stating what could not be run as a numbered device checklist (rule 16, docs/grok-agent.md §9 format) and says 'a doc line with no such test is a plan, not a fact'. docs/android.md has no such checklist. + +**Proposed fix** + +State in android/README.md and docs/android.md that :app has not yet been built by AGP or run on a device. Add a numbered device checklist covering pairing (QR, code, deep link), SSH and relay attach, WebView terminal and OSC 52, notifications, and Keystore persistence. Consider holding the desktop 'nodeterm for Android' link until a CI build has passed. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Drop the build claims and downgrade the severity to low. +> +> Wrong in the original finding: +> - ".github/workflows/android.yml has never run" and ":app has never been built by AGP" are both false. Run 36109984730 on head 2f58918 passed both jobs, `:app:assembleDebug` included, and uploaded a 15.8 MB debug APK. +> - The list "aapt2, manifest merger, D8 unverified" is false. +> - R8 does not apply (`isMinifyEnabled = false`, android/app/build.gradle.kts:22). +> - Do not tell the docs to say ":app has not been built by AGP". That would now be false, and docs/android.md:85-86 is correct as written. +> +> The remaining finding is: +> - The app has never been installed or run on a device or emulator, and no doc says so. +> - The android/README.md:11-26 ✓ table has no caveat. +> - docs/android.md has no numbered device checklist in the rule-16 / grok-agent.md §9 format. +> +> Proposed fix: +> - Add a one-line caveat near the ✓ table, e.g. "the APK is built by CI; no row has yet been checked on a device — see docs/android.md §Device checklist". +> - Add that numbered checklist to docs/android.md. Cover: +> - QR scan via CameraX/ML Kit, pasted code, and the `nodeterm://pair` deep link from the system camera +> - SSH attach and TOFU pin on a real LAN +> - relay attach plus SAS approval +> - WebView terminal input bar, special keys, swipe scroll and OSC 52 clipboard +> - WorkManager inbox notifications, including the Android 13+ POST_NOTIFICATIONS grant +> - Keystore persistence across app restart and reinstall +> - the cold-start resume offer +> +> Holding back the desktop "nodeterm for Android" link is optional rather than needed, since a build has passed. Note that ANDROID_APP_URL points at eneskirca/nodeterm tree/main/android, which only exists once this branch merges upstream. + +## A66 + +**ANDROID_APP_URL points at a folder that exists on neither upstream nor fork main yet, and it points at source code rather than an installable** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: gap +- Location: `src/renderer/lib/links.ts:7` + +**Evidence** + +The constant is `export const ANDROID_APP_URL = 'https://github.com/eneskirca/nodeterm/tree/main/android'`. This checkout's origin is github.com/CPlusPlus17/nodeterm, and `git ls-tree origin/main android` is empty. The android/ folder exists only on claude/android-ios-parity-75kfem, so the link 404s until this lands on eneskirca/nodeterm main specifically. A merge into the fork alone leaves it broken. Even when the folder exists, the link sits beside 'Get the nodeterm iOS app ↗' (PhonePairPopover.tsx:196-201) and after 'Don't have the app yet?' (PhoneSection.tsx:205-219), but it opens a Gradle source tree, not a download. bugReport.ts:7 already defines `REPO_URL` for the same repository. + +**Proposed fix** + +Derive the link from `REPO_URL` and merge upstream before shipping the copy. Label it as source/build instructions (for example, 'Android (build from source) ↗') until a downloadable release exists. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Line references: +> - PhonePairPopover.tsx: the iOS button is 191-196 and the Android button is 197-202. +> - PhoneSection.tsx: the Android button is 213-218, inside the "Don't have the app yet?" paragraph at 205. +> +> Root cause: the dead link is a timing artifact, not a shipping defect. It resolves when the branch merges to eneskirca/nodeterm main, and releases are cut from upstream v* tags. The part that stays true after the merge is the wording: "nodeterm for Android ↗" sits beside an App Store link and after "Don't have the app yet?", but the target is a Gradle source tree. The only APK is an expiring CI run artifact that needs a GitHub login. +> +> Better fix: +> - Relabel both call sites as source/build, e.g. "nodeterm for Android (build from source) ↗". Once a signed APK is published, point the link at a GitHub Release asset. +> - Deriving the URL from REPO_URL (`${REPO_URL}/tree/main/android`) removes the duplicate constant. It does not change behaviour on a fork, because REPO_URL is also hardcoded to upstream. + +## A67 + +**The interop fixture is excluded from every tsconfig, so `npm run typecheck` never checks it against the desktop interfaces it implements** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: gap +- Location: `tsconfig.node.json:17` + +**Evidence** + +tsconfig.node.json's `include` covers electron.vite.config.ts, src/main, src/preload, src/shared, src/core, src/server and src/session-host. tsconfig.web.json covers src/renderer and src/shared. Neither covers android/protocol/src/test/interop/host-fixture.ts. That file implements `HostPtyManager` and the kanban/inbox/nodeActions bridge (host-fixture.ts:97-204) and is only bundled by esbuild, which strips types without checking them (InteropHarness.kt:67-75). It passes tsc today (I checked it with a scratch config extending tsconfig.node.json), but nothing keeps it that way. + +**Proposed fix** + +Add `android/protocol/src/test/interop/**/*.ts` to tsconfig.node.json's include (or a small dedicated tsconfig run by `npm run typecheck`). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line reference is slightly off: the `include` array in tsconfig.node.json is at lines 18-26 (line 17 is the closing brace of `compilerOptions`). The implementing code spans host-fixture.ts:97-212, not 97-204. The esbuild call in InteropHarness.kt is at lines 67-71 (about 65-75 including the setup around it). +> +> Two additions to the finding: +> - android.yml's path filter leaves out `src/core/**`, but the fixture imports `src/core/platform` and `src/core/pty-manager`. Some desktop drift therefore skips even the runtime interop tests. +> - The `pty as unknown as PtyManager` cast at host-fixture.ts:178 means even a type-checked fixture only checks the `HostPtyManager` interface, not its fit to `PtyManager`. +> +> The proposed fix works: add `android/protocol/src/test/interop/**/*.ts` to tsconfig.node.json's `include`. I verified the file compiles clean under that config, with `@types/ws` resolving from the repo's node_modules. + +## A68 + +**Current follow-up (2026-10-05):** source-fixed in `7e91785c` with main-only push, retained PR filters, no merge-group trigger and opt-in manual beta preparation. Tests, mutations and the exact-head CI route are recorded in the latest workflow-readiness paragraph below. The original finding and verifier corrections remain historical evidence. + +**Workflow triggers break repo conventions: every branch push plus pull_request doubles runs, and there is no merge_group** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: risk +- Location: `.github/workflows/android.yml:6` + +**Evidence** + +`push:` has only `paths:` and no `branches:` (lines 6-11), and `pull_request:` has the same paths (lines 12-17). For a PR branch in this repo, each commit therefore runs both jobs twice. The concurrency group `${{ github.workflow }}-${{ github.ref }}` separates refs/heads/ from refs/pull//merge, so neither run cancels the other. ci.yml:7-11 and security.yml:10-14 restrict `push` to `branches: [main]` and add `merge_group:` because 'required checks must also report on the queue's synthetic merge commits'. android.yml has no merge_group, so the queue's combined commit is never tested against the Android contract. + +**Proposed fix** + +Use `push: branches: [main]` like ci.yml, keep `pull_request` (with widened paths), and add `merge_group:`. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Keep the duplicate-run finding (android.yml:6-17 and 20-22), severity low. +> +> Fix: add `branches: [main]` to `push:` and keep its `paths:` filter, so the merged commit on main is still tested. Keep `pull_request:` with `paths:` (widening the paths is a separate matter). That removes the duplicate runs on same-repo PR branches, and possible tag-push runs as well. +> +> Drop the merge_group reasoning or reframe it. The merged commit is tested post-merge through push-to-main. The workflow cannot be a required check because of its `paths` filters, not because merge_group is missing. And merge_group takes no `paths` filter, so adding it would run Android on every queued PR. Only add merge_group if Android is deliberately made a required check. In that case, replace the workflow-level `paths` filter with an in-job path check that always reports a status. + +## A69 + +**The Gradle/Kotlin code has no dependency-update, CodeQL or wrapper-validation coverage** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: gap +- Location: `.github/dependabot.yml:4` + +**Evidence** + +dependabot.yml has only the `npm` and `github-actions` ecosystems. There is no `gradle` entry for /android or /android/protocol, whose crypto and network stack is pinned in protocol/build.gradle.kts:28-34 and app/build.gradle.kts:78 (okhttp 4.12.0, sshj 0.39.0, bcprov-jdk18on 1.78.1). security.yml:40 limits CodeQL to `languages: javascript-typescript`, so the new Kotlin NaCl port, relay socket, host-key pinning and Keystore code get no static analysis. android.yml runs a committed binary, android/gradle/wrapper/gradle-wrapper.jar (sha256 7d3a4ac4…), without a wrapper-validation step, and it has no Gradle cache, so every run downloads the distribution, AGP and all dependencies again. + +**Proposed fix** + +Add Dependabot `gradle` entries for /android and /android/protocol. Add `java-kotlin` to CodeQL (build-mode none works without the Android SDK). Add `gradle/actions/wrapper-validation` (or setup-gradle, which also caches) to android.yml. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Three corrections, one of them material. +> +> 1. Line numbers. +> - `dependabot.yml:4` is a comment; the ecosystem entries are at lines 5 and 36. +> - The pins are at `protocol/build.gradle.kts:27` (okhttp), `:30` (sshj) and `:32` (eddsa), not lines 28-34. +> - `app/build.gradle.kts:78` (bcprov) is correct. +> +> 2. The CodeQL fix as proposed would analyse nothing. `build-mode: none` for `java-kotlin` covers Java source only; Kotlin needs a build, and every file under `android/` is Kotlin (43 `.kt`, 0 `.java`). Adding `java-kotlin` with build-mode none would scan nothing while looking like coverage. The fix needs `build-mode: manual` (or autobuild) with a build step: +> - `./gradlew -p protocol compileKotlin` needs no Android SDK. +> - `:app:compileDebugKotlin` works on ubuntu-latest, which ships the Android SDK. +> +> 3. The impact wording is stronger than I could verify. Dependabot security alerts come from GitHub's dependency graph, not from `dependabot.yml`. I believe (from memory, not checked) that Gradle coverage in that graph needs the dependency submission API. If so, the complete fix also adds `gradle/actions/dependency-submission`; that would also make the existing `dependency-review` job in `security.yml` see Gradle changes. +> +> The rest of the proposed fix stands: Dependabot `gradle` entries for `/android` and `/android/protocol`, and `gradle/actions/setup-gradle` in `android.yml`, which validates the wrapper and caches. Adding `distributionSha256Sum` to `gradle-wrapper.properties` would also pin the distribution itself. + +## A70 + +**On Windows the interop tests fail with CreateProcess instead of skipping** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: bug +- Location: `android/protocol/src/test/kotlin/dev/nodeterm/protocol/InteropHarness.kt:66` + +**Evidence** + +The harness does `val esbuild = File(repoRoot, "node_modules/.bin/esbuild")` and then `ProcessBuilder(esbuild.path, ...)` (lines 66-68). `available()` gates only on that file existing (line 83). On Windows, npm also creates the extensionless sh shim, so the gate passes. CreateProcess cannot run a shell script, though: it looks for `esbuild.exe`, which does not exist beside esbuild.cmd. The lazy `bundle` then throws, and the test errors instead of skipping. CLAUDE.md treats Windows as a first-class desktop target and requires platform-bound tests to be gated. + +**Proposed fix** + +Invoke esbuild through node (`node node_modules/esbuild/bin/esbuild …`) or through its JS API, or resolve `esbuild.cmd` on Windows. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The root cause and the line numbers are right. Minor wording: CreateProcess may fail with error 193 (tries the sh file) rather than error 2 (looks for esbuild.exe); either way the result is an IOException. +> +> The proposed fix is incomplete, and on its own it is harmful. If only the esbuild call is changed (for example `node node_modules/esbuild/bin/esbuild ...`), bundling succeeds on Windows and two new problems appear in PairingInteropTest: +> +> (a) The pairing tests are platform-bound. The fixture calls `createPairingService(..., { timeoutMs: 60_000 })` without a `platform` option (host-fixture.ts:237-247). On win32, `directSsh = platform !== 'win32'` is false (pairing-service.ts:440), so the QR carries `ssh:false` (line 652). The first test's `assertTrue(payload.sshAvailable)` (PairingInteropTest.kt:53) then fails. +> +> (b) The tests' home isolation does not work on Windows. PairingInteropTest.kt:38 sets only `HOME`, but pairing-service.ts:259-260 resolves `AGENT_DIR` from `os.homedir()`, which reads `USERPROFILE` on Windows. The relay-on test would reach the win32 branch, which mints and then calls `persistDevice({... token: agentToken ...})` (pairing-service.ts:803-822). That writes a paired test device, with a live bearer token, into the contributor's real `%USERPROFILE%\.nodeterm\agent.json`. +> +> A correct fix has two parts: +> 1. Invoke esbuild through node, not the `.bin` shim, so RelayInteropTest can run on Windows. +> 2. Either gate PairingInteropTest off Windows (`assumeFalse(System.getProperty("os.name").startsWith("Windows"), reason)`), or have the fixture pass `platform: 'linux'` and have the test also set `USERPROFILE` to the temp home. + +## A71 + +**android/README says 'JDK 17+', but the pinned Gradle 8.14.3 cannot run on JDK 25** + +- Severity: **low**; claimed by auditor: low; effort: small; area: ci-docs; kind: gap +- Location: `android/README.md:36` + +**Evidence** + +The README says 'Needs JDK 17+ and the Android SDK'. The wrapper pins `gradle-8.14.3-bin.zip` (gradle-wrapper.properties:3). Gradle 8.14 supports running on Java up to 24; Java 25 support arrived in Gradle 9.1. A contributor whose default JAVA_HOME is the current LTS (25) gets a Gradle startup failure from `./gradlew`. CI pins 17 (android.yml:47-50, 68-71), and the protocol tests were run locally on 21. + +**Proposed fix** + +State the supported range ('JDK 17–24; Android Studio's bundled JDK works'), or bump the wrapper to a Gradle version that supports 25 (check AGP compatibility). + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line (android/README.md:36) and the conclusion are correct. The finding says only "Gradle startup failure"; the precise cause is the Kotlin 2.0.21 compiler embedded in Gradle 8.14.3. Its `com.intellij.util.lang.JavaVersion.parse` rejects "25"/"25.0.1" with an IllegalArgumentException. This fires while settings.gradle.kts compiles, so the user sees "What went wrong: 25.0.1". The Kotlin DSL's own JVM-target mapping clamps unknown versions to JVM_22, so that part is safe. +> +> Fix options: +> 1. Simplest: change the README to "JDK 17–24 (Android Studio's bundled JDK works; JDK 25 needs Gradle 9.1+)". +> 2. Add `gradle/gradle-daemon-jvm.properties` with `toolchainVersion=17`, so the daemon runs on 17 whatever JAVA_HOME is. This is incubating in 8.14, and the launcher still starts on JAVA_HOME. +> 3. Bump the wrapper to Gradle 9.1+. This needs its AGP compatibility with AGP 8.9.1 checked first; I have not verified that. + +## A72 + +**Current follow-up:** A102 now includes trusted project env/shell and A103 preserves measured per-agent policy; old evidence below describes the earlier gap. + +**Phone-started sessions are created without the agent-specific env, so for their whole life they get no hook-reply approvals, no canvas control and no pane ownership** + +- Severity: **low**; claimed by auditor: medium; effort: medium; area: critic; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:229` +- Note: Related: A08. + +**Evidence** + +New session works by attaching a fresh `term-…` id and typing the launch line (TerminalController.kt:229-241; the docs/android.md table says 'pty.attach of a fresh term-… id, launch line, then projects.registerNode'). The relay attach passes no agentId (host-service.ts:432), and the desktop's own comment at pty-manager.ts:2254-2259 says these spawns carry 'no ownerProjectId — and no cwd, agent, account…'. hookServer.buildPtyEnv therefore omits `NODETERM_AGENT_ID`, `NODETERM_PERM_WAIT_SECS` and `NODETERM_CANVAS_CONTROL`, all gated on agentId (hook-server.ts:1249-1251). Pane ownership is recorded only when an ownerProjectId is supplied (pty-manager.ts:2309-2310). The node is registered afterwards, so the desktop mounts it warm, and tmux ignores `-e` on an existing session (CLAUDE.md: 'Sessions are pinned for life'). Nothing ever adds the missing env. + +**Proposed fix** + +Pass the intended agentId (and project and account) through the create path: add optional `agentId/projectId/accountId` to `pty.attach` for a not-yet-existing session, validated host-side like registerNode, or register first and let the desktop spawn with a held launch. Then the session gets the same env a canvas-started node does. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The behaviour is real, but four details need correcting. +> +> 1. **Root cause is on the desktop, not in the Android code.** The gap is in the relay host's `pty.attach` verb (`host-service.ts:432` passes only `{cols, rows}`), and the desktop documents it at `pty-manager.ts:2254-2259`. Android is copying the iOS new-session flow (`docs/android.md:43`), so iOS presumably has the same gap. The fix belongs on the desktop side, with the Android client sending the extra fields. +> +> 2. **"No hook env" is too broad, and so is the desktop's own comment.** `hookEnv` is still built for every `persistKey` (`pty-manager.ts:2893-2896`). `NODETERM_NODE_ID` and `NODETERM_HOOK_ENDPOINT` are present, so status badges and inbox events still work. What is missing is only the agent-gated part: `NODETERM_AGENT_ID`, `NODETERM_PERM_WAIT_SECS`, `NODETERM_CANVAS_CONTROL` and the Codex launcher PATH. Approvals still work from the phone through the keystroke fallback (`QuickActions.kt:36-38`), so this is a degraded path, not a broken one. +> +> 3. **Severity should be medium-low.** +> - Agent messaging is off by default (`agentMessagingDefault`). +> - Every pane is already unproven after a desktop restart. +> - Plain Codex is a supported mode. +> - The real losses are deterministic approvals and canvas control for Claude sessions started from the phone. +> - The user can repair a session on the desktop with "Restart agent and shell", which recycles the session so it respawns with the right env. +> +> 4. **Of the two proposed fixes, only the first is sound.** +> - "Register first and let the desktop spawn" does not work in general. The desktop only mounts nodes of the active project, and nothing spawns a registered node in a background project. The phone's own `pty.attach` would still create a bare session. +> - The workable fix is optional `agentId` (and account) on `pty.attach`, applied only when the session does not yet exist. The host should validate the value against builtin/custom agents and let `buildPtyEnv`/`canControlCanvas` decide the grant. +> - `ownerProjectId` must be resolved on the host (the index entry id), not taken from the phone. Otherwise it breaks `pane-ownership.ts`'s rule that ownership never comes "off the wire". + +## A73 + +**Notifications are documented as 'live every 8 seconds while a computer is open', but the in-app poll never posts a notification** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/notify/InboxNotifier.kt:125` +- Note: Related: A25. + +**Evidence** + +InboxNotifier.announce has exactly one caller: InboxWorker.doWork (InboxNotifier.kt:125), the 15-minute WorkManager job. HostSession's 8 s poll (ConnectionManager.kt:212-221) only updates `_snapshot`. Yet the kdoc says '…plus the in-app 8 s refresh while a computer's screen is open' (InboxNotifier.kt:37-38). The Settings switch tells the user 'Checked about every 15 minutes in the background, and live while a computer is open.' (SettingsScreen.kt:84). android/README.md:55-56 says '…and live every 8 seconds while a computer is open'. + +**Proposed fix** + +Call InboxNotifier.announce from HostSession after each successful refresh, skipping events the user is currently looking at (e.g. the open terminal's node, or the Inbox tab). Alternatively, correct the copy in all three places. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The line is correct: :125 is the only caller, and the definition is at :71. Three corrections: +> +> 1. **Impact.** On the HostScreen, the Inbox tab badge ("Inbox (N)", HostScreen.kt:70,112) already shows new actionable events live. The unannounced cases are the terminal screen, other paired computers, and the app in the background. +> 2. **The proposed fix only covers the computer being watched.** "Call announce from HostSession after each refresh" works only for that computer, because other computers' sessions are not polled at all (`startWatching` is per-host and is started only by HostScreen and TerminalScreen). So it does not fix the "on another computer's screen" case the finding describes. +> 3. **Better fix.** Either: +> - Call `announce` from `refreshNow` and skip the node open in the terminal. This also needs the seen-set updated for events shown on the Inbox tab, or the worker will later re-announce events the user already saw. +> - Or, simpler and honest: change the three copy sites to say notifications arrive only from the ~15-minute background check, and that the Inbox tab updates live while a computer's screen is open. +> +> Severity: medium is arguable, low-medium is more accurate. It is a documentation promise that is not kept, not a broken headline flow. + +## A74 + +**The LAN leg dials a DHCP IPv4 frozen at pairing time; when another SSH host answers at that address, the host-key 'hard stop' also blocks the relay fallback** + +- Severity: **low**; claimed by auditor: medium; effort: small; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt:88` + +**Evidence** + +PairedHost.host is the QR's `host` (PairedHost.from, PairedHost.kt), which the desktop fills with `pickLanIPv4(os.networkInterfaces())` (pairing-service.ts:588). Nothing ever updates it: late adoption reads relay.json but not the address. connectLocked tries SSH to that address first in Auto. On a HostKeyChangedException it sets Failed and throws immediately: 'A changed host key is a security signal … do not quietly route around it' (lines 88-93), so the independently authenticated relay leg is never attempted. The error text says 'someone may be intercepting the connection'. The same private address commonly belongs to a different SSH-running machine: the computer's lease reassigned at home, or the phone on another network using the same 192.168.x.y range. + +**Proposed fix** + +Key the SSH pin to the computer rather than to the IP, and on a mismatch skip SSH but still try the relay (its identity is pinned separately, so no trust is lost). Refresh the LAN address when you can: publish it in `~/.nodeterm/relay.json` or the mirror and update it over the relay, or re-resolve it from a stable hostname. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two parts of the finding are overstated and should be corrected. +> +> 1. The user is not locked out until they re-pair. SettingsScreen.kt:98-110 has a per-computer route override, "Only through the relay" (`RoutePreference.RELAY_ONLY`), which skips the SSH leg (ConnectionManager.kt:76) and reaches the computer through the relay. The real problem is that the error text never mentions that setting. It tells the user to "remove and re-pair", or implies an attack. Severity is medium at most: the case is narrow and there is a manual workaround. Low is also defensible. +> +> 2. The proposed fix "key the SSH pin to the computer rather than to the IP" is based on a misreading. The pin is already stored per paired computer, not per IP: `PairedHost.sshHostKeyFingerprint`, read through `pinFor(host)` via `graph.hosts.get(host.id)` (ConnectionManager.kt:154-157). What goes wrong is the stale dial target, not how the pin is keyed. +> +> Better fix: +> - In AUTO, when `HostKeyChangedException` is thrown, record and show the warning but keep going to the relay leg, which has its own pinned identity. Keep the hard stop for SSH_ONLY. +> - Change the message to point at the "Only through the relay" setting. +> - Optionally, have the desktop publish its current LAN address (for example in relay.json, or in the relay's `projects.list` reply). The phone could then refresh `host.host` after a successful relay connect. Clear the SSH pin only if the relay-authenticated computer confirms the new host key. + +## A75 + +**The New session account picker lists managed Claude accounts by raw UUID** + +- Severity: **low**; claimed by auditor: low; effort: small; area: critic; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt:316` + +**Evidence** + +The picker renders `Text(id ?: "System account")` for each `snapshot.status?.settings?.claudeAccounts` entry (SessionsTab.kt:316-320). The mirror's settings block carries only `{id, dir}` (agent-status-mirror.ts:184 `claudeAccounts?: { id: string; dir: string }[]`). The same snapshot does carry each account's `label`/`email` in `usage.accounts` (buildMirrorUsage, agent-status-mirror.ts:299-321; parsed into UsageAccount), but the picker never looks there. + +**Proposed fix** + +Resolve display names from `snapshot.status?.usage?.accounts` (label, else email) by accountId, falling back to the id. Longer term, add label/email to MirrorSettings.claudeAccounts. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> The finding is correct. Two refinements: +> +> 1. **Line numbers.** The rows are built at SessionsTab.kt:316 and the `Text(id ?: "System account")` call is at line 319; the source list is set at line 292. +> +> 2. **The fix only works on the desktop.** Only the desktop shell calls setMirrorUsageProvider (src/main/index.ts:3890). src/server/index.ts has no buildMirrorUsage/setMirrorUsageProvider call, so a Server Edition host publishes settings.claudeAccounts but no usage block. Usage can also be empty before the first poll. Looking the name up in `usage.accounts` (label, else email, else id) therefore only helps desktop hosts once usage has loaded. +> +> The durable fix is an additive `label` (and optionally `email`) field on MirrorSettings.claudeAccounts. It should be written by both producers (src/main/index.ts:2112 and src/server/index.ts:460, which already have `a.label` from settings) and parsed into ManagedAccount on Android. Old readers ignore unknown fields. + +## A76 + +**Over direct SSH, opening a Sleeping (Eco-hibernated) session lands on a bare shell with no wake or resume offer** + +- Severity: **low**; claimed by auditor: low; effort: small; area: critic; kind: gap +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt:247` + +**Evidence** + +Eco exits the CLI and leaves a shell in the pane. Over the relay, attaching wakes it: host-service calls `remoteViewer.attached`, which fires `agent:wake` (index.ts:3986-3989). Over SSH nothing tells the desktop about the attach. SshHostConnection.wake is `relayOnly(...)` (SshHostConnection.kt:251), and SessionsTab hides 'Wake' unless `capabilities.nodeActions` (SessionsTab.kt:150-156). The phone's own resume offer fires only when `s.fresh` (TerminalController.kt:247), and a hibernated session is not fresh. The app knows both `hibernated` and the sessionId (Launch.resumeCommand) but offers nothing. + +**Proposed fix** + +Show the same resume banner when `snapshot.statusOf(nodeId)?.hibernated == true` on an SSH attach, reusing Launch.resumeCommand with the node's cwd and account. Or route the wake through the relay when a relay leg exists. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> Two details are overstated or wrong. +> +> 1. **"No in-app way" is too strong.** Settings has a route choice, "Only through the relay" (SettingsScreen.kt:104, `RoutePreference.RELAY_ONLY`). If remote access is set up, switching to it makes the next attach go over the relay, and that attach wakes the node. The user can also type the resume line by hand. The real gap is narrower: in the default AUTO route on the LAN, the app shows nothing for a Sleeping session. +> +> 2. **The proposed fix's signature is wrong.** `Launch.resumeCommand(agent, sessionId)` (Agents.kt:31) takes no cwd or account. None are needed either: the hibernated pane already sits in the node's cwd, and its tmux session env already carries the account's CLAUDE_CONFIG_DIR / CODEX_HOME. +> +> The simplest fix is to widen the condition at TerminalController.kt:247 from `if (s.fresh)` to `if (s.fresh || status?.hibernated == true)`, and word the banner as "Wake ". +> - It stays an offer the user accepts, never typed unasked, matching the existing rule. +> - The desktop's `hibernated` flag clears itself on the resumed CLI's SessionStart / live hook states, so a resume started from the phone leaves the desktop consistent. +> - One weaker spot: the desktop's wake checks that a shell owns the pane before typing, and uses KILL_LINE. This banner does neither, but the user can see the pane before tapping. + +## A77 + +**IME insets are not handled for Android 15's enforced edge-to-edge (targetSdk 35): the terminal gets double bottom padding when the keyboard opens, and other screens have no IME padding at all** + +- Severity: **low**; claimed by auditor: low; effort: small; area: critic; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalScreen.kt:77` + +**Evidence** + +targetSdk = 35 (app/build.gradle.kts), so edge-to-edge is enforced on Android 15+. `android:statusBarColor` in themes.xml is ignored and adjustResize no longer resizes the window. TerminalScreen applies `Modifier.padding(padding).imePadding()` (line 77) without `consumeWindowInsets(padding)`. Material3 Scaffold only observes consumed insets and does not consume them for content: the material3 1.8.2 bytecode shows `onConsumedWindowInsetsChanged` and `exclude`, but no `consumeWindowInsets`. The bottom inset is therefore navigation bar plus IME height, and the IME inset already includes the navigation bar. PairScreen's paste field and SettingsScreen's 'Relay API' field, at the bottom of a scrolling Column, have no imePadding at all. + +**Proposed fix** + +Use `Modifier.padding(padding).consumeWindowInsets(padding).imePadding()` in TerminalScreen, and add `imePadding()` to the scrolling columns in PairScreen and SettingsScreen. Call `enableEdgeToEdge()` so behaviour is the same on API < 35. + +**Verifier corrections and refinements** (these take precedence over the proposed fix where they disagree) + +> 1. **Scope is Android 15+ (API 35) devices only, not "all devices with targetSdk 35".** +> - On API 26-34 the decor still fits system windows because the app never enables edge-to-edge. +> - There the root layout consumes the system-bar and IME insets, so Compose's `WindowInsets` read 0. Scaffold's padding and `imePadding()` are both no-ops, and `adjustResize` really resizes the window. +> - So TerminalScreen, Pair and Settings behave correctly there. +> +> 2. **`enableEdgeToEdge()` is optional consistency, not part of the fix.** +> - The needed change is `consumeWindowInsets(padding)` before `imePadding()` at `TerminalScreen.kt:77`, plus `imePadding()` on the scrolling Columns at `PairScreen.kt:92` and `SettingsScreen.kt:69`. +> - Adding `enableEdgeToEdge()` would move API 26-34 onto the inset path as well, so every screen would then depend on its inset handling. +> +> 3. **The PairScreen claim is narrower than stated.** +> - The paste field (`PairScreen.kt:109-114`) sits fairly high on the screen: after two text lines and a button, and it is 120dp tall. On a typical portrait phone it usually stays above the keyboard. +> - What is more likely covered is the "Use this code" button beneath it (line 115), and the field itself in landscape or on short screens. +> - The Settings "Relay API" field is the stronger case. +> +> 4. **Version note.** The bytecode cited (material3 1.8.2) is the JetBrains desktop stub. The Android build uses material3 from BOM 2025.06.00 (1.3.x), which has the same Scaffold inset logic. + + +## A78 + +**Desktop quick-answer false success (continuation, 2026-10-02).** + +Re-read at cached branch tip `6afd8f53`: `PtyManager.backgroundWrite` sent live answers through +its painter and released answers through a name-targeted control command. Neither cancelled copy +mode; a missing name could match a longer session name. Multi-step delivery also needs to preserve +write order and pin the resolved pane across awaits. + +The local fix resolves `=session:` to a numeric pane ID once, cancels copy mode there and sends hex +bytes to that same pane. Complete calls queue per node, including live and released paths; another +node remains independent. Unconfirmed channels are retired without retrying keys. Regression tests +judge delivered bytes, prefix collisions, active-pane changes, cancellation failure and concurrent +write order. Real-tmux cases are added but cannot run in this socket-restricted sandbox. + +## A79 + +**Direct-SSH quick-answer false success (continuation, 2026-10-02).** + +`SshScripts.sendKeys` used an exact session target but did not leave copy mode, so tmux could return +zero while the application received nothing. `SshHostConnection.sendKeys` also accepted a missing +SSH exit status as success. + +The local fix resolves and validates the pane ID, cancels copy mode and types into that ID, and +requires exit status zero. Lookup/cancel/send failures propagate. Three generated-shell regression +tests pass under a cached compiler; four shell mutations and the null-as-success mutation are +caught. Full protocol, MINA, real tmux and device execution remain pending. + +## A80 + +**Control-mode startup reply consumes a queued command (continuation, 2026-10-02).** + +`ControlModeClient.start` launches `attach-session`, which has its own control reply block. The +client previously queued only stdin commands and shifted that queue for every reply. When a pane +probe was queued immediately after start, the empty startup reply resolved it; its actual reply +then belonged to the next command. A socket-free reproduction against the real client confirmed +this mismatch. + +The local fix reserves and consumes the startup reply before resolving stdin commands, and retires +the client if attach fails. Behavioral regressions feed the initial reply after a command is +already queued and verify that only the command's own reply resolves it. Test children now emit the +startup block too. Full real-tmux and device verification remain pending. + +## A81 + +**Relay HTTP token requests can stall indefinitely (continuation, 2026-10-02).** + +`RelayApi` shared the long-lived WebSocket client, whose read timeout is deliberately zero, with +join/device HTTP calls. A server accepting a connection but stalling its headers or body could +leave the app connecting indefinitely. Blocking `execute` inside `withContext(IO)` did not cancel +the call when the coroutine was cancelled. + +The local fix gives HTTP its own finite I/O limits and a 30-second total deadline, applies that +deadline even to an injected client, and keeps coroutine cancellation bound to `Call.cancel` until +the response body is closed. A coroutine deadline also covers OkHttp dispatcher queueing while +preserving an outer caller's cancellation. WebSocket reads and wire shapes are unchanged. Six regression methods +run real OkHttp over in-memory sockets, covering both endpoints, stalled headers/body, trickling +body, cancellation, dispatcher queueing, caller deadlines and successful wire shapes. Three initial +and three follow-up mutation checks are caught. Required Gradle, live +backend, roaming and device verification remain pending. + +## A82 + +**Shared read-ack files are stolen by other desktops (continuation, 2026-10-02).** + +The local sweeper read and deleted every `.seen` file under `~/.nodeterm/acks` before establishing +ownership. The remote shell did the same glob for a shared SSH host. Whichever desktop swept first +could consume another desktop's acknowledgment, leaving its unread badge/card intact forever. + +Local consumption now requires positive mirror ownership, including an unresolved own inbox card +whose old node entry expired after restart. Unknown files remain unread and untouched; retained +files bypass the directory-mtime cache because ownership can appear without another phone write. +The remote manager forms the union of every connected project's nodes per host and sends validated +IDs on stdin; the shell reads/deletes only that allowlist, and its output is checked against it. +Regression tests cover two owners, late ownership, expired-node inbox cards, multiple projects on +one host and refusal of unexpected output. Android interop uses the actual `SshScripts.ackRead` +producer against the desktop's real local/remote consumers. File names/content stay compatible with +iOS; @eneskirca should validate multi-desktop behavior. Full checks and device execution remain open. + +## A83 + +**Release R8 cannot parse Kotlin 2.2 metadata (local build, 2026-10-02).** + +The first actual local `:app:assembleRelease` completed under AGP 8.9.1, Kotlin 2.2.0, wrapper +Gradle 8.14.3 and JDK 21, but emitted many R8 Kotlin-metadata parsing warnings. A successful task +therefore did not establish that the shrinker supported the metadata in its inputs. The APK was +not accepted as the final beta. + +The configuration fix in `fa71cb08` changes only AGP to 8.10.1 in `android/build.gradle.kts`; +Kotlin 2.2.0, Gradle 8.14.3 and JDK 21 remain unchanged. `GradleCiCoverageTest` now guards the +Kotlin/AGP/R8 compatibility boundary and AGP's Gradle minimum. Eight bounded guard tests pass and +two mutations are caught. These checks establish the version policy, not an app build. + +The corrected actual release and final offline `:app:assembleRelease` succeeded; the metadata +warnings are gone, and every R8 runtime keep passed. That APK was privately signed, verified, +installed and cold-started on an MI8 (Android 15 / API 35), then removed when it was identified as +the wrong phone. Its newly authorized SSH key was removed; no host was paired or SSH connection +attempted. The same beta is subsequently installed on the intended Pixel with basic manual SSH +proof, and its corrected code-3 update verifies `A85` sizing/pre-attach tmux history. These results +establish the build fix; the full phone pass stays open under `A50`. +The test-only `A84` fix passed all 606 protocol tests. The compatibility sources are linked from the guard: +[Android Kotlin support](https://developer.android.com/build/kotlin-support) and +[AGP 8.10 release notes](https://developer.android.com/build/releases/agp-8-10-0-release-notes). + +## A84 + +**Real SSH tests inherit host login hooks and share interactive Readline state (local verification, 2026-10-02).** + +- Severity: **low**; effort: small; area: tests; kind: bug +- Location: `android/protocol/src/test/kotlin/dev/nodeterm/protocol/SshTransportTest.kt` + +The first restored-host full protocol run executed 605 tests: 603 passed, two real-SSH tests +failed, and none were skipped. Both failures are confirmed harness isolation faults. The literal +leading-dash test types `-R; echo sk_$((2+3))` into a login Bash pane; Fedora's PackageKit +`command_not_found_handle` delays that command beyond the assertion deadline. The earlier test +which refuses a missing SSH exit status still delivers its lone Escape byte into the shared pane. +Readline keeps that state for the next test and turns its `echo` into `cho`. + +The test-only fix in `1d6b04cc` starts a fresh primary pane for each test with a non-login shell and +isolates its initialization environment. It retains real SSH/tmux execution and the literal +leading-dash and missing-exit-status assertions. Both isolation mutations were caught, the fixed +source was restored, and the final full rerun passed all 606 tests with zero failures, errors or +skips. This was not an observed phone/APK failure; production app source and the `fa71cb08` APK +build are unchanged. + +## A85 + +**WebView WRAP_CONTENT layout parameters collapse the terminal's CSS viewport to one row (device verification, 2026-10-02).** + +- Severity: **medium**; effort: small; area: terminal; kind: bug +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt`, `createWebView` + +On the intended Pixel 10 Pro (Android 17 / API 37, Vanadium WebView `154.0.8037.92.0`), private beta +`0.1.0-beta.1` / code `2` opens a large native terminal view but advertises 52/56 columns by one +row, despite native bounds `[0,396][1280,2448]`. The one-row height persists after a font change. +Manual SSH authentication, real project +listing and a harmless command in a controlled temporary test tmux window work; swipe does not +enter copy mode or expose old history in this one-row state. + +The WebView lacked explicit layout parameters, so AndroidView supplied `WRAP_CONTENT`. +[Chromium's AwLayoutSizer](https://chromium.googlesource.com/chromium/src/+/HEAD/android_webview/java/src/org/chromium/android_webview/AwLayoutSizer.java) +sets forced zero layout height from that height policy. Large measured native bounds therefore +do not establish a nonzero CSS viewport, and FitAddon clamps terminal rows to one. + +The minimal fix in `febe022a` sets both layout dimensions to `MATCH_PARENT` before loading the +terminal page; CSS and JavaScript are unchanged. The real Gradle `TerminalWebViewLayoutTest` +wiring guard passes, and the height-`WRAP_CONTENT` and removed-assignment mutations are caught in a +temporary source mirror. Actual retained-signer beta `0.1.0-beta.2` / code `3` from +`febe022ad2fc373f27ac11d9ad5f130f36f027a5` passed its offline AGP build (45 seconds), signature, +alignment/provenance verification and in-place update. Its host configuration, SSH key/pin and +notification grant survived. The controlled terminal now fills 52×45, and a downward swipe enters +tmux copy mode at position 82. A screenshot visibly shows the pre-attach ready/sentinel and marker +rows 001–039 from 120 rows printed before update/attach. History is restored by the native layout +fix; no production SSH-scroll change was needed. A− restores font 13 and 56×48, the soft keyboard +changes it to 56×25, and hiding the keyboard restores 56×48. Esc leaves copy mode; a second +harmless draft command executes with its whole output line visible. The owned temporary test +window alone was removed, with the previous window/process intact and intended-phone app/key/config +retained. + +**User-reported mobile check:** with WireGuard enabled and Wi-Fi off, the user confirmed that the +intended Linux host's terminal opens over mobile data. This is separate from the ADB-assisted LAN +checks above. Mobile reconnect, approvals/questions, background behavior and the full 64-item +checklist remain open. + +History regressions in `d6619bf6` pass 47 focused real Gradle SSH/terminal/link tests with zero skips. Both JavaScript swipe-direction/disabled-scroll mutations, the real-SSH wheel-direction mutation and the two native layout-policy mutations were caught; production sources were restored. **Final verification:** all 609 protocol tests passed in 59 suites with zero failures, errors or skips (52 seconds); the offline app `compileKotlin` passed (7 seconds). + +Checklist items 20 and 23 +cover history and viewport sizing without adding or removing any of the 64 items. + +## A86 + +**Scroll responsiveness is poor despite reachable tmux history (post-beta investigation, 2026-10-02).** + +- Severity: **medium**; effort: medium; area: performance; kind: gap +- Locations: `android/app/src/main/assets/terminal/terminal.js` touch handlers, + `TerminalController.Bridge.onScroll`, `SshHostConnection.SshStream.scroll` + +The user reports slow scrolling on both Wi-Fi and mobile-data VPN after the signed-beta checkpoint +`cf0487a3`, so a VPN-only cause is not supported. History is reachable; the issue is responsiveness +and gesture behavior. The initial investigation changed no source, installed APK or phone setting. + +In a bounded private MINA/SSH/tmux fixture at 52×45, 240 wheel notches over two seconds move 1195 +history rows: the first notch enters copy mode and later notches move five rows each. Production +JavaScript at checkpoint `cf0487a3` emits a notch per roughly 18.2 CSS pixels at font 13 and has no fling. This amplifies +drag distance into coarse steps. A single 700-CSS-pixel movement requests 38 notches, but SSH clamps +the call to 20, losing distance. + +The fixture's connected socket has TCP_NODELAY disabled. At 30/60 Hz, its output-tail measurement +is about 40.8 ms with that setting versus 1.2–1.4 ms enabled; at 120 Hz both remain around 41 ms. +Writer-queue tails stay below 0.5 ms, so this controlled loopback run shows no host writer backlog. +These results do not establish TCP_NODELAY as the sole cause. Android uses xterm's DOM renderer, +while desktop defaults to WebGL. + +The actual 194452-byte, two-second SSH capture was replayed through bundled xterm's DOM renderer +in Electron 42 / Chrome 148 under Xvfb, at 426×684 CSS pixels and DPR 1. Normal `renderRows` mean +was 0.14 ms, p95 at most 0.3 ms; with JIT-less mode requested, mean was 1.5 ms and p95 at most +1.9 ms. Pending writes peaked at one and 1622 bytes, with no final backlog, unrendered output or +long tasks and a stable 60 Hz animation-frame cadence. This excludes base64/DOM queue backlog as +the primary cause at this bounded desktop load; it does not establish Pixel performance. + +**Implemented mitigations (2026-10-02).** `e6bdb157` enables TCP_NODELAY on the connected SSH +socket. `245b42e6` measures row height once per gesture and accounts for stock tmux's five rows per +wheel notch, batches same-direction movement by animation frame and preserves fast-swipe distance +in ordered calls of at most 20 notches. `2c5d15a8` adds one bounded serial `TerminalActions` drain +per accepted stream; suspended relay RPCs cannot reorder reversals or input. Input cancels unsent +scrolls and follows the in-flight call; retiring the viewer clears/cancels the queue. Native raw +chips and resume writes cancel page scrolling first, and callbacks remain bound to their accepted +stream. `40c4ee49` also cancels scrolling immediately before the delayed paste Enter. No host verb, +payload or SSH-visible file contract changed; this fix needs no iOS payload/interop fixture change. + +The full restored-source protocol suite passed **638 tests in 61 suites**, with zero failures, +errors or skips (51 seconds); the final offline app `compileKotlin` passed (7 seconds). **30 mutations** were +caught: two SSH socket-policy, fourteen JavaScript gesture/input and fourteen actor/native-wiring +mutations. Private beta `0.1.0-beta.3` / code `4` from +`40c4ee49592e2f92fc7e6e9b548ba88a33b1e2d3` built locally in 49 seconds, passed every R8 keep +and retained-signer packaging; signature, source/hash provenance and alignment were verified, with +all 149 ZIP payloads unchanged by signing. Its in-place update on the intended Pixel succeeded, +preserving manual SSH registration/key/pin and notification permission. Push is +user-authorized and each requested push requires green Android workflow verification; no PR is +requested and `A68` remains deferred. + +**Controlled Pixel proof.** The updated app reopens the intended Linux host over SSH and the owned +test terminal fills 56×48. Five identical downward swipes of 1000 native pixels over 350 ms produced +history positions 25, 40, 55, 70, 85 on beta 2, versus 5, 10, 15, 20, 25 on beta 3. Reversal moved +25 to 20; the actual Esc chip left copy mode (`pane_in_mode=0`). A screenshot records the controlled +test history. The phone returned to Sessions and refreshed; only the owned test session was removed, +with user panes untouched. These establish reduced drag gain, reversal and input cancellation, not smoother +rendering. Isolated `gfxinfo` samples contained only 11/12 frames and 5/6 janky frames respectively; +they establish no FPS improvement and are not a WebView renderer trace. + +**Superseding user failure and next correction.** The user reports that beta 3 still has both lag +and too little movement on Wi-Fi and mobile-data VPN. Its controlled mechanical results above do +not establish satisfactory responsiveness. `A87` fixes report-triggered cancellation and restores +one measured row per notch, keeping frame batching, lossless ordered chunks, lifecycle barriers and +TCP_NODELAY. All 646 protocol tests in 62 suites pass with zero failures/errors/skips, and offline +app `compileKotlin` passes (8 seconds); 22 JavaScript and nine actor/native-wiring mutations are caught. +Code-5 beta built/signed and updated in place; user feel remained open at that checkpoint. +The beta-6 verification below now resolves the primary drag/coast complaint. + +**Controlled beta-3 phone trace.** Twelve alternating gestures in an owned 56×48 dummy-history +terminal produce one JavaBridge invocation per gesture, 24 RAF callback collections and 11 distinct +presented pipelines. First invocation is 84–90 ms after touchstart; first presentation 170–196 ms is +correlation without an input→SSH→render flow. Presented Chromium scroll events measure 36–56 ms; +>1-second aggregate EventLatency mainly counts no-paint termination. No named JS/native methods or +scheduler/V8 measurements exist, and some newer extension fields are unparsed. Sparse FrameTimeline +and non-damaging ScrollJank events establish no terminal FPS/jank rate or renderer cause. These +measurements support comparing delivered updates after the correction, not blaming JIT or claiming +a one-second paint. No RTT emulation has run. + +The matching beta-4 trace records 42 bridge invocations versus 12, 26 content commits versus 12, +and 25 distinct presentations versus 11 over twelve gestures. First invocations occur at 25–36 ms +versus 84–90 ms; this supports more frequent delivered updates, without establishing terminal FPS +or end-to-end SSH latency. Both traces and queries are retained in the private beta-4 evidence. + +**Beta-4 user feedback and bounded fling correction.** The user reports that beta 4 moves more +lines but lacks momentum after finger lift. `1ad2e944` adds a velocity fling sampled over 120 ms, +released within 80 ms of recent movement at 0.45–3 CSS pixels/ms. Exponential decay uses a +240-ms constant, stops at 0.06 pixels/ms, and has hard 1000-ms/1200-pixel caps. Existing calls +carry at most 20 notches; frame gaps over 250 ms stop movement. `53462f96` adds `onScrollStop` +for new touch/input/reset/font/lifecycle barriers without typing into the pane. Queued keys/replies +and the in-flight operation survive; bytes already handed to SSH's writer/network cannot be recalled. +Automatic xterm reports preserve coast. No host/payload/SSH-file contract or iOS fixture changed. + +All 658 protocol tests in 63 suites pass with zero failures/errors/skips (59 seconds), plus offline +app `compileKotlin` (10 seconds). Thirty-five JS and eleven new native mutations were caught; +seven strengthened kinetic tests and real bundled-xterm coast/report behavior pass. Private +`0.1.0-beta.5` / code `6` uses source `1ad2e94455a7adfb85d41212b12d36df39695324`; actual AGP +release built in 47 seconds and passed every R8 keep. Retained-signer packaging verifies metadata, +signature, 16-KB alignment and source/hash provenance. APK SHA-256: +`7cc68d384aeb21ab40800fa7c83f006dfbfefba16dd2e945967c8c5376f17655`. +It updated the intended Pixel in place with code-6/non-debuggable metadata and notification +permission confirmed. The user still reports continuous swiping stops and requires lifting; the +controlled test shows no coast after command completion. `A89` records the detached target cause +and stable-screen fix. Beta-6 build/sign/update and SSH reopening with retained key/pin pass. +Controlled Pixel continuous dragging, post-command coast and Esc stopping now pass; a held +touch also stops coast at a stable 56×25 viewport (see `A89`). + +The user confirms normal continuous dragging and coast “Both work now”, resolving the primary +complaint. Separately, the user's final beta-6 cellular WireGuard SSH check confirms connection +and smooth scrolling. +**Still open:** reversal/lifecycle, custom wheel bindings and FPS. +Earlier new-touch checks opened the IME and resized tmux, so those results were inconclusive. +Custom tmux wheel bindings and FPS remain open. Existing checklist item 20 covers these checks; +the checklist remains 64 items. + +**Native custom-wheel follow-up (2026-10-06).** A new `SshTransportTest` method runs the actual +`SshHostConnection` and `TerminalActions` against private native tmux over SSH. Emacs copy-mode +uses up/down row gains 3/2, vi uses 7/4. Staged requests exceeding the 20-notch transport limit +reach all expected positions; queued up37/down73/up29 returns through the live bottom and exposes +FIFO reordering. All eight positions settle for 300 ms and original bindings/options are restored. +Native control/restored runs pass; reversed direction, dropped wheel write and host clamp20→1 +each fail the exact emacs up37 distance assertion (111 expected, 0/0/6 observed). +The first test-setup cancellation failure is preserved separately and earns no mutation credit. +See [the native receipt](android.md#native-custom-wheel-checks-2026-10-06). No production behavior, +APK or wire/file contract changes; the phone/FPS/lifecycle matrix and original device ledger remain open. + +## A87 + +**Automatic xterm reports cancel a swipe and discard queued movement (2026-10-02).** + +- Severity: **medium**; effort: medium; area: runtime; kind: bug +- Locations: `android/app/src/main/assets/terminal/terminal.js` input handlers, + `TerminalController.Bridge`, `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/TerminalActions.kt` + +The installed beta-3 page treats every xterm `onData` event as user input. xterm also emits focus, +mouse and terminal-query replies on that event. `cancelScroll()` clears the page queue and the +active touch position; native `TerminalActions.write()` drops unsent movement. A report during a +gesture therefore stops its subsequent moves, despite no user key or paste. The pinned xterm 5.5 +CoreService marks keyboard/paste/IME input before `onData`, but also marks SGR mouse reports as +user input; using that flag alone still misclassifies mouse reports. + +**Fix:** `3cffb49d` consumes the pinned input-origin event once, excludes full SGR mouse reports, +and routes automatic `onData` and legacy `onBinary` replies through `Bridge.onReport`. The bridge +captures its accepted stream and checks the page generation. `TerminalActions.report()` uses the +same bounded FIFO and serial drain while preserving pending scroll distance/direction; ordinary +user `write()` retains its cancellation barrier. Report errors do not retry or discard following +movement. Keys, paste and IME input still cancel scrolling. Gesture gain returns to one measured +text row per wheel notch; ordered frame batching, lossless 20-notch chunks, lifecycle retirement, +SSH TCP_NODELAY and delayed-Enter cancellation remain. + +Real bundled xterm/Fit/page regressions exercise touch, focus, SGR/legacy mouse, query replies, +keyboard, paste and IME. Actor regressions cover reports between suspended chunks/reversals, +user-input barriers, shared bounds and non-retryable report errors; source pins verify generation, +stream identity, snapshot ordering, JavascriptInterface and no Ctrl transformation. All 646 protocol +tests in 62 suites pass with zero failures, errors or skips; offline app `compileKotlin` passes (8 seconds). +Twenty-two JS and nine actor/native-wiring mutations were caught. This proves the routing/queue +policy, not corrected phone feel; beta-4 delivery and controlled movement pass below. At that +checkpoint user follow-up remained open; the later beta-6 drag/coast confirmation is under `A89`. + +The code-5 local AGP release built in 48 seconds and passed every R8 keep. Retained-signer +packaging verified non-debuggable APK metadata (minSdk 26, targetSdk 35), signature, alignment +and source/hash provenance. APK SHA-256: +`c64d6a8dea9621265f23a679104149e511ecd243fbdfa53ced401f4cb4f0f6b5`. +The intended Pixel updated in place, preserving manual SSH configuration/key/pin and granted +notifications; the installed app remains non-debuggable. Its owned 56×48 terminal now reaches +positions 25, 45, 70, 100, 130 on the same five 1000-native-pixel/350-ms swipes, versus beta 3's +5, 10, 15, 20, 25. Each gesture produces 4–6 observed host-position changes, reversal moves +130 to 110, and the actual Esc chip leaves copy mode. Private inputs/proof are in +`.nodeterm/android-beta-build-4/` and the APK/checksum/metadata in `.nodeterm/android-beta-4/`. +These verify delivered movement/order/cancellation, not terminal FPS or satisfactory user feel; +at that checkpoint corrected-beta user follow-up remained open under `A86`; beta 6 now has +normal drag/coast confirmation. + +**Residual:** a direct xterm/browser unbracketed user paste whose entire content exactly matches +an SGR mouse report can still take the automatic-report path. The native draft submission now +cancels JS/native scrolling before paste (`53462f96`/`1ad2e944`). Recheck this edge if the input-origin adapter changes. +No host verb, payload, mirror or SSH-visible file contract changed, so iOS owes no payload fixture +update for this fix. Revalidate the pinned internal xterm input-origin adapter on a bundle upgrade. + +## A88 + +**New SDK signer labels reject a correctly signed private-beta artifact (2026-10-02).** + +- Severity: **medium**; effort: small; area: tooling; kind: bug +- Locations: `android/tools/package-beta.py` `verified_signer`/signing command, + `test_beta_signer.py`, `test_package_beta.py`, `test_beta_sdk.py` + +The original verifier assumes one `Signer #1 certificate SHA-256 digest` line. `fed68fb3` +added strict SDK-range labels and explicit v2 signing, with 32 local tests and ten mutations. +CI run `37058184031` at `9ced6781` still failed five of 32 private-packaging tests at the same +signer/v2 gate; protocol, debug, release and CodeQL passed. The runner image includes SDK 37, +and the old fixture chose its highest lexical tools/platform instead of the installed SDK 36/35. +The raw CI verifier report was not captured. A local reproduction with official SDK 37 now +confirms a real format discrepancy: verification exits 0, v2 is true and there is one signer, +but its certificate label is `V3.0 Signer:`. The earlier range-only hypothesis did not cover it. + +**SDK-37 follow-up (`f5fd3821`):** accept exact `V2 Signer:`/`V3.0 Signer:` single labels with a required +`Number of signers: 1`, and exact V3.0/V3.1 SDK-range labels with the existing one-certificate, +non-overlapping-range policy. Older indexed/range reports remain supported. Consider every APK +signer certificate line except source stamps; reject duplicate or conflicting certificates and +unknown/V3.2 hybrid classical/PQC identities rather than silently ignoring them. The expected certificate pin and +verified v2 gate are unchanged; signing explicitly enables v2. Fixtures now require the explicitly +installed SDK 36/platform 35, with exact reproduction overrides, and signature-gate failures +include safe verifier diagnostics from a disposable test key/APK only. + +All 39 Python tests pass against each real SDK 36/37; six new parser and four fixture-selection +mutants are caught. Removing the single-label support also kills the actual SDK-37 acceptance +fixture. Proof/logs are in `.nodeterm/android-beta-build-4/ci-verification/` (working evidence: +`/tmp/nodeterm-beta4-ci-check/`). Follow-up [run `37061593216`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37061593216) +at `c37798b6495b4b68df379d0ae80887c23104b66d` completed with all five jobs green, confirming +observed CI repair. Each subsequent push still needs its own green workflow. Local reproduction +establishes the SDK-37 bug, not captured output from the original failed job. +No private key, APK, app code or phone setting changed from this tooling correction. + + +## A89 + +**Repaint detaches the touched text span and loses continued drag/release events (2026-10-02).** + +- Severity: **medium**; effort: small; area: runtime; kind: bug +- Locations: `android/app/src/main/assets/terminal/index.html` terminal CSS, + xterm DOM renderer row spans, `TerminalJsXtermInteractionTest` + +The user reports that beta 5 stops during continuous swiping and only moves again after lifting. +The intended Pixel's controlled gesture updates history 0→15 at 369 ms, 25 at 391 ms and 30 at +412 ms, then remains fixed through 1.4 seconds after the ADB swipe command completes at 561 ms. +That is command completion, not a measured physical touchend timestamp. There is no verified coast. +The real bundled DOM renderer replaces row text spans during a redraw, detaching the element +that received touchstart. Later touchmove/touchend still target that detached span and no longer +bubble to the live page handlers. Tests dispatching events directly on the terminal host missed +this target-lifetime failure. + +**Fix (`c4b1f6cf`):** set `.xterm-screen { touch-action: none; }` and +`.xterm-rows, .xterm-rows * { pointer-events: none; }`. Hit testing chooses the stable screen +behind painted text, keeping later move/end events connected across redraws. Existing fling, +ordered 20-notch batching, automatic-report routing and native stop/input/lifecycle barriers remain. + +The actual bundled-xterm regression verifies the old span is removed, subsequent events on it +lose the live handler, and computed production CSS instead targets the connected screen so +continued movement and release coast survive a real redraw (24 notches versus one). A real +Electron 42.11.3 / Chromium 148.0.7778.280 probe of the shipped page confirms `elementFromPoint` +hits the stable connected screen across redraws; original CSS hits the detached span. This closes +the jsdom hit-test limitation for desktop Chromium, not actual Pixel touch behavior. All 658 +protocol tests in 63 suites pass with zero failures/errors/skips (67 seconds), plus offline app +`compileKotlin` (8 seconds). Three CSS mutations are caught alongside 35 JS and eleven native +mutations. Private `0.1.0-beta.6` / code `7` uses source `c4b1f6cf1009f293a658b6331d2ed1ab80aa36c6`; +actual AGP release built in 43 seconds and passed every R8 keep. Retained-signer packaging verifies +metadata/signature/16-KB alignment/source hashes and preserves all 149 payloads. APK SHA-256: +`4947133a6ccf9c2b1e775e76d7c24f564e087cf036e59eac4dca08162a076d3c`. +The intended Pixel received a same-signer update preserving app data; notification permission +is confirmed. SSH reopened with the retained key/pin at 56×48. A 1000-native-pixel/1200-ms swipe +produced 23 observed positions from 0 to 110 over about 1103 ms (ADB command complete at +1535 ms). A 1000-pixel/200-ms swipe reached 110 at command completion (540.5 ms), then 250 at +1339 ms, about 799 ms later, with 29 observed updates overall. Continuous drag delivery and +post-command coast pass. Esc during coast left copy mode and remained out for 1.4 seconds. +New-touch stopping also passes with the keyboard already open and all sampled viewports at +56×25. After a 500-native-pixel/100-ms swipe, the DOWN command completed 152 ms after the swipe +command; the script then waited 1.2 seconds before issuing CANCEL. The last position change was at 503.6 ms, before DOWN completed at +588.8 ms; final position 100 remained unchanged through CANCEL. Earlier IME-resizing tap/DOWN +checks rebased tmux positions and were inconclusive, not additional failures. Evidence includes +`beta6-stable-viewport-touch-stop.json` and `stable-touch-check.log` in the private beta-6 proof. +Esc and Header Back then returned to Sessions and detached the owned client; only its exact +owned tmux session and phone UI XML were removed. Private evidence, including +`device-summary.json`, is in `.nodeterm/android-beta-build-6/` and `.nodeterm/android-beta-6/`. +No host verb, payload, mirror or SSH-visible file contract changed; no iOS fixture change is owed. +The user confirms normal continuous dragging and coast both work now. Reversal/lifecycle, +custom wheel bindings, FPS and the full device pass remain open. The user confirms final beta-6 +connection and smooth scrolling on cellular WireGuard with Wi-Fi off over regular manual SSH; +cellular hosted relay remains untested. + + +## A90 + +**Manual SSH/WireGuard host cannot create a new plain terminal without a local desktop/relay (2026-10-03).** + +- Severity: **medium**; effort: medium; area: parity; kind: gap +- Status: **implemented and host-verified** in `bcc92367` / `b88d1415`; beta 10/code 11 installed. + Focused physical plain-SSH and beta 10 relay plain-shell creation/input/exact End pass; + item 32 stays Partial for managed/cellular variants. + The separate A91 otherwise-empty-host flow passes on beta 9 Oct4. +- Locations: Android Host screen/new-terminal choice, SSH scripts/connection and host listing + +The user can browse and open existing sessions on their Linux host over manual SSH/WireGuard, +but cannot create a new shell. Another desktop drives that host's `nodeterm-rmt` sessions and +project files; the host has no own workspace or relay. `A26`'s canvas New-session flow still +requires `projects.registerNode` on the owning desktop, so it cannot serve this request. + +**Implemented design:** explicitly create a plain shell on the separate `nodeterm-phone` socket +in a discovered host folder or Home. Creation marker/session metadata rediscover it under a +synthetic **Phone terminals** group; the shell survives disconnect and End addresses only its +exact owned session. Clear inherited nodeterm identities before starting the shell, and configure +only this socket for the terminal's mouse/history/UTF-8 behavior. Do not modify shared project +files, borrow an existing node's hook token or claim desktop-canvas registration. Desktop and +Server Edition keep scanning/reaping their own `node-terminal` / `nodeterm-rmt` sockets. + +The `A08` refusal to create a missing managed/canvas session and existing relay New session stay +intact. No current host RPC, projects blob, pairing payload, mirror or SSH-visible file contract +changes. **iOS follow-up for @eneskirca:** consider the isolated phone socket, explicit creation +marker and session metadata rather than a canvas append for this independent-shell feature. + +Atomic ID/resolved-cwd/request/fingerprint session environment allows rediscovery even if creation +stops before option finalization. The parser verifies the request's SHA-256, and actions pin the +validated fingerprint before checking live ownership. Reserved phone UUIDs never attach to either +desktop socket or send a creating relay RPC. Same-ID/folder retries retain a shell after directory +rename. New panes clear inherited managed identities and warm non-UTF-8 overrides; partial attach +restores phone-only settings. Host-owned creation survives dismissal/background, and Main +navigation checks the visible screen/ticket before opening. + +**Verification:** the full real protocol suite passes **684 tests / 66 suites**, zero +failures/errors/skips, in 48 seconds; final offline app `compileKotlin` passes in 1 second. +Nine new real SSH/tmux methods, four pure phone-metadata tests and a relay interop refusal cover +creation/folders, quoting/idempotency, interrupted discovery, reconnect/history/End, stale/foreign +ownership, warm environment and reserved socket IDs; helper/wiring regressions cover UI lifecycle. +All **32 new mutants are caught**: eleven actual Gradle/Kotlin 2.2 protocol behavioral variants, +twelve helper behavior variants and nine native wiring variants. These are host/fixture results, +not physical phone results. + +Historical `0.1.0-beta.8` / code `9`, clean source `b88d141528c1051964da07faf22cc7fa923c4846`, was +**installed on the intended Pixel, now superseded by beta 9**. The actual offline AGP release builds in 49 seconds; R8 keeps, +retained-signer packaging, 16-KB alignment and source/hash provenance pass. Independent SDK 36/37 +tools verify v2/v3 signatures and one retained signer, all 149 unsigned payloads are byte-preserved +with three signing entries added, all four ELF PT_LOAD alignments pass, and R8/service mapping +agrees with the source/version/hash/build inputs. Terminal assets/native libraries are unchanged +from beta 6. APK SHA-256: +`d373ad5c1790f714cb4464ad4a0a38c5ba9ab68e35103e54cf3aef5ce53081ce`. +Private artifact/proof are in `.nodeterm/android-beta-8/` and `.nodeterm/android-beta-build-8/`. + +The 28.05-second exact-Pixel same-signer update verifies matching pre/post APK hashes and signer, +code 9/name beta 8/non-debuggable metadata, retained install identity/notification grant and an +existing manual-SSH host row. Receipt: `.nodeterm/android-beta-build-8/device-install-20261003/receipt.json`. +The existing host reconnects over SSH and lists real driven projects; New terminal is visually +present and its FAB is enabled/clickable in own-app UI XML. No existing pane was touched and no +terminal was created or ended. This is installation/reconnect/browse/UI-availability proof, not +Create/cwd/history/reconnect/End or TalkBack proof. Historical beta-6/code-7 +device evidence and prepared unused beta 7 / code 8 are preserved; at that installation checkpoint +the ledger remained seven Pass / +20 Partial / 37 Pending. After the hike, pair on current beta 9 then use a later same-signer +higher-code update for item 1, keeping the working app installed. For item 32 use Linux host → +Sessions → New terminal → Home/project/custom absolute folder → Create; verify real cwd/input/history, +disconnect/app-restart rediscovery and exact owned End on the intended Pixel over SSH/WireGuard. +Existing desktop sessions, project files and canvas must remain unchanged. Full relay/managed-agent +creation and the rest of the 64-item matrix remain separate pending requirements. + +**Completed focused Pixel proof:** beta 8/code 9 creates Home, discovered-project and custom-folder +shells with verified cwd/input, pre-attach history, drag/coast/Esc, no duplicate from rapid double +Create and visible missing-folder error/corrected retry without an orphan. Force-stop/restart +retains three shell PIDs/history and SSH rediscovery. Beta9/code10 update, reconnect and same-pane +native input pass. Native UI End of custom→project→Home removes exactly each selected UUID, +retains sibling immutable fingerprints/PIDs, changes counts 3→2→1→0 and removes the final Phone +terminals group. Eleven desktop session IDs/pane PIDs and nine protected project/workspace hashes +are unchanged. Owned shells, empty fixture folders and UI dumps are removed; the phone returns +to regular host Sessions. Proof: +`.nodeterm/android-beta-build-8/a90-pixel-check-20261003/final-focused-results.json` and +`beta9-exact-ui-end.json`. Only item 32 is promoted to Partial: current **7 Pass /21 Partial /36 Pending**. +The Oct3 proof does not verify relay canvas/managed or cellular creation or the full checklist. +The separate A91 otherwise-empty-host last-End variant passes on beta 9 Oct4 below. The isolated bare-Esc/paste contamination was a QA input +artifact, and clean native input passed; it did not produce a paste finding. + +## A91 + +**Ending the last phone shell on an otherwise empty SSH host leaves its cached row visible (2026-10-03).** + +- Severity: **medium**; effort: small; area: runtime; kind: bug +- Status: **fixed in `4d33a5b5`, delivered in installed beta 9/code 10**. + The physical otherwise-empty-host flow passes on beta9 Oct4; A94 is delivered in beta 10 with its full focused empty-host flow passed. +- Locations: `android/app/src/main/kotlin/dev/nodeterm/android/conn/ConnectionManager.kt` + (`HostSession.refreshNow`), `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ListingFailure.kt` + +When the last phone-owned shell is ended and the SSH host has no workspace, driven sessions or +other phone shells, browse throws `NothingFoundException`. The session has ended, but native +refresh previously kept the last successful snapshot, so Sessions still showed its row. A lost +reply or host refusal cannot prove nodes are gone; blindly clearing every failed listing would +also discard useful cached rows during an outage. + +**Fix:** `ListingFailure.snapshot` returns `ProjectsSnapshot.EMPTY` only for the authoritative +`NothingFoundException`; generic host/transport failures retain the previous snapshot, and +cancellation rethrows before replacement. `ConnectionManager` applies the policy while preserving +the route-specific error and existing connection rules. The authoritative empty answer leaves +SSH connected and New terminal available. No current RPC, projects blob, pairing, mirror or +SSH-visible file contract changes. + +**Verification:** four `ListingFailureTest` methods use an actual parsed phone listing to verify +last-row removal, generic-error retention, cancellation identity and native error/connection +wiring. Full real Gradle protocol passes **688 tests / 67 suites**, zero failures/errors/skips, +in 52 seconds; offline app `compileKotlin` passes in 6 seconds. All six isolated Kotlin 2.2/JDK 21 +mutants are caught, and independent review finds no blocker. Ignored XML/mutation proof is in +`.nodeterm/android-beta-build-9/`. + +**Physical otherwise-empty-host pass (2026-10-04):** installed beta 9/code 10 creates and opens +its first Home shell through New terminal. Native End removes the exact row and Phone terminals +group while SSH stays connected. New terminal remains usable and creates/opens a second Home +shell. That second shell was retained for the beta 10 update and subsequently ended in the full +A91/A94 flow below; final fixture/service cleanup is not yet claimed. Proof: +`.nodeterm/android-beta-build-9/checklist-20261004/a91-beta9-focused-results.json` plus UI receipts. +This closes A91's focused empty-host physical variant. Item 32 stays Partial and the ledger stays +At that checkpoint7 Pass /21 Partial /36 Pending because relay/managed and cellular creation remain open; +later paired-update and item 36 Board verification bring the current tally to 10 Pass / 22 Partial / 32 Pending. +A94's separate false Loading label was observed; beta 10 delivers the correction and its full installed End/recreate/open/End completed-empty flow passes. + +Private beta `0.1.0-beta.9` / code `10`, clean source +`4d33a5b5366c99479b648086649205350c7752b1`, built in 43 seconds and updated the exact intended +Pixel with the retained signer in 6.46 seconds. Its pulled installed APK matches SHA-256 +`719cfeea1dcf27900dd35692a59004ca07e8261b3f14bd43922f0706b6b4ab54`. +Three owned beta-8 shells survived and were rediscovered after updating, then all were ended +through verified exact-session cleanup. Historical source CI run +`37144282865` failed the device-checklist documentation mapping; debug/release APK and CodeQL +passed, private packaging was skipped. Recorded prior all-green branch is `19da35a2`, all five jobs in +run `37140762345`; the next push needs its own checks/green workflow. +This revision adds the A91 item 32 mapping and isolates its Known gaps paragraph, without +weakening the test. The recorded local 688/67 source baseline predates the final documentation; +fresh full protocol/offline app gates follow the frozen docs and exact-head CI proof is retained +separately after the next push. +Independent SDK 36/37 review verifies signature/alignment, all 149 unsigned payloads preserved, +four ELF alignments, expected R8/service metadata and source/hash provenance. The non-debuggable +installation preserves install identity, notification grant and app data; pairing/relay credential +survival is not proved. Receipts: `.nodeterm/android-beta-build-9/artifact-review.json` and +`.nodeterm/android-beta-build-9/device-install-20261003/receipt.json`; private APK: +`.nodeterm/android-beta-9/nodeterm-android-0.1.0-beta.9.apk`. + +## A92 + +**Desktop/Server link lookup can exclude the hovered row after a long wrapped run (2026-10-04).** + +- Severity: **low**; effort: small; area: desktop renderer; kind: bug +- Status: **fixed in `127b6b28`; native Linux Desktop UI verified at `01b4a2f5` with A121's leave +fix**. Separate Linux Server checks pass at `dcdf664a`. The later GPU-enabled eight-case matrix +passes (Canvas4 hardware / Modal4 DOM); Those earlier runs did not verify GPU recovery; later individual pilots are recorded in the final +follow-up. macOS remains unverified. +- Locations: `src/renderer/terminal/file-links.ts` (`paragraphContaining`) and `file-links.test.ts` + +This was the desktop follow-up recorded while fixing Android `A32`. The exported helper walks up +as many as 32 continuing rows, then joins at most 32 rows downward. Hovering row 32 or later in +a full-width run can therefore return a paragraph ending before the requested row. URL lookup +on a tail row can offer only `https://x` instead of the complete `https://x.io/a`. + +**Fix:** reserve the requested row by limiting the upward walk to `MAX_JOIN_ROWS - 1`, retaining +the 32-row total budget. This affects the shared Desktop/Server renderer only. Android's +`terminal.js` already has the corresponding `A32` fix; no Android APK change, host-service verb, +projects blob, pairing payload, mirror or SSH-visible file change is involved, and no iOS +adoption is owed. + +**Verification:** the 24-test focused Vitest file passes, including exhaustive containment/text +checks for every row in 80-row hard/soft wrapped runs and actual URL-provider range/activation on +a tail row beyond the cap. All 54 tests across the three affected link/dialect files and the full +`npm run typecheck` pass. Three isolated production-copy mutants are caught: restore the old +upward bound, expand the total budget, or omit the hovered tail from the downward window. +Control and restored copies pass. Private evidence: `/tmp/nodeterm-file-link-fix-verify/results.json` +and per-variant logs. No active source was changed for mutation runs; desktop `out/` remains the +previous `071735d6` build while the isolated production pairing fixture runs. Android's installed +beta 9 and the 7 Pass / 21 Partial / 36 Pending ledger are unchanged; no new phone results are claimed. + +## A93 + +**Fresh-desktop remote-on pairing succeeds without relay credentials (2026-10-04).** + +- Severity: **medium**; effort: medium; area: pairing/hosted relay; kind: interop/backend +- Status: **open**. A backend refusal is confirmed; its recovery policy and a repair are unverified. +- Locations: src/main/pairing-service.ts (mintRelayDevice and the direct-SSH pairing branch), + android/app/src/main/kotlin/dev/nodeterm/android/ui/PairScreen.kt (previous token lookup), + and the external hosted /v1/relay/device endpoint. + +**Observed:** the intended Pixel, still on private beta 9/code 10, accepted genuine pairing JSON +from a fresh isolated production desktop with remote access on. Local pairing succeeded, but +the saved host received no relay credential. A bounded retry using the same request body as production +mint returned HTTP 403 with error reauth_required. No backend repository was read or changed. + +**Relevant state:** the previous owned fixture had been forgotten on the phone, removing its +saved relay token while preserving the global phone identity. The fresh desktop has a different +device/host-key identity. Android looks for a prior token by the pairing's host key; production +desktop code forwards it when supplied, and carries its own normal device id. These facts +suggest a backend re-registration/C2 constraint, but do not establish the backend's exact rule. + +For a direct-SSH desktop, production pairing deliberately retains its working SSH registration +when relay mint fails. That explains local success; it does not establish working “from anywhere” +access or complete checklist items 8/12/17. Historical beta-6 hosted-relay proof remains valid. +No phone identity reset, individual desktop identity/token copying or user-secret import was +performed. Ordinary restart/re-pairing of the complete original owned fixture profile now +succeeds, preserving host/device identity and issuing actual relay credentials on beta 9. +After the retained-signer code10→11 update, beta 10 reconnects through the saved relay pairing, +opens the owned terminal and sends input to its real scoped PTY. An anchored SSH refusal warning +remains before AUTO relay fallback; this does not claim OnlyRelay preference survival. This verifies one legitimate +same-desktop recovery case, not the backend's full C2 policy or fresh-different-desktop repair. +**iOS implication for @eneskirca:** check prior-token lookup and recovery messaging when Forget +removes a relay token but persistent phone identity remains. No new external field/fixture +change is introduced by this finding. +New private evidence belongs in .nodeterm/android-beta-build-9/checklist-20261004/. +Item 1 paired-update and item 36 rebuilt-Board verification pass; item 35 stays Partial, giving current 10 Pass / 22 Partial / 32 Pending. + +## A94 + +**Completed empty SSH listings still show “Loading sessions…” (2026-10-04).** + +- Severity: **medium**; effort: small; area: native Sessions state; kind: runtime bug +- Status: **fixed in 3c217cba; delivered in beta10/code11**. Five actual Kotlin regression + methods/eight isolated mutants and full Gradle 689/67 zero failures/errors/skips pass; + the full installed End/recreate/open/End completed-empty flow passes. +- Locations: android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/ListingFailure.kt, + ListingFailureTest.kt and android/app/src/main/kotlin/dev/nodeterm/android/ui/SessionsTab.kt. + +**Physical failure:** the intended Pixel's beta 9 finishes listing the otherwise-empty private +OpenSSH fixture and shows the completed error banner, Over SSH and an available New terminal. +Its Sessions content nevertheless keeps “Loading sessions…”. Private before-fix proof: +.nodeterm/android-beta-build-9/checklist-20261004/a91-initial-empty.json and .png. + +A91 correctly replaces stale rows after NothingFoundException, but used ProjectsSnapshot.EMPTY +with fetchedAt=0, the marker for a first listing still pending. SessionsTab chooses its loading +label from that timestamp. The fix stamps this authoritative empty answer with its completion +time, retaining cancellation and the prior uncertain-failure cache/route/error behavior. +No external host-service, pairing, mirror or SSH-file contract changed; no iOS adoption is owed. +The clean e3ce041c snapshot builds private beta10/code11 in 52.4 seconds; R8 keeps and independent +SDK36/37 artifact review pass. The retained-signer update is installed on the intended Pixel, +with matching SHA-256 04ace0ea01540bb477fcf823395a299fa211cfef3745aa28674099038ceb3693, +install identity/notification grant and the owned SSH pane preserved. Full protocol passes 689 +tests/67 suites with zero failures/errors/skips (86.08 seconds wall time including a new daemon), +offline app compile passes in 6.78 seconds. The exact built source `e3ce041c` has all five CI jobs green in [run `37194375778`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37194375778). Each later documentation push needs its own green workflow. The installed full End/recreate/open/End completed-empty flow passes. +Beta9 A91 last-End/recreate proof remains separate. No item 32 promotion follows. +Item 1 paired-update and item 36 Board verification pass, giving 10 Pass / 22 Partial / 32 Pending. + +**Installed A91/A94 focused empty-host cycle passes (2026-10-04).** On beta10/code11, +native End of the otherwise-empty fixture's sole owned Home shell removes its row/group and +shows “No sessions on this computer yet.” with no Loading label; Over SSH and the data-not-found +banner remain. New terminal → Home → Create opens a new actual bash pane in Home. Back to +Sessions and exact native End removes that new UUID and restores the same completed empty +screen. All three isolated host sockets are empty. Proof: +`.nodeterm/android-beta-build-9/checklist-20261004/beta10-a94-full-cycle-result.json`, +`beta10-a94-recreated-pane.json` and the matching empty-screen UI receipts. This passes the +focused A91/A94 empty-host flow; item 32 remains Partial because managed and cellular +creation remain open; the relay plain-shell variant passes. Total 10 Pass / 22 Partial / 32 Pending. Final fixture/service cleanup is +not yet claimed. + + +## A95 + +**Phone Board move and label persist but the mounted desktop Board remains stale (2026-10-04).** + +- Severity: **medium**; effort: small; area: desktop/phone interop; kind: runtime bug +- Status: **fixed in `ec12ea9a`; rebuilt production desktop physical verification passes**. + Actual beta 10 phone move/remove/re-add/create-label checks complete item 36; Android APK is unchanged. +- Locations: `src/core/workspace-store.ts` (`kanbanWriteNow`/`announceProjectFile`), + `src/renderer/canvas/Canvas.tsx` (`onExternalChange`/`onServerChange`), Desktop + `src/preload/index.ts` (`workspace.onServerChange`) and the mounted + `src/renderer/components/kanban/KanbanView.tsx`. + +**Observed:** on the installed beta10/code11 Pixel, the phone's Board moves its owned +relay-created plain shell to In Progress. Its card moves and the actual persisted project +board assigns that exact node to the In Progress column. The already mounted desktop Board +still places it in Ungrouped at two snapshots approximately 60 seconds apart, with unchanged +page time origin and no reload. A new blue label is also created and applied through the phone: persisted metadata and the +mobile card have it, while the mounted desktop still has no labels. The same view had already +picked up phone New-session node +creation and Rename, so those successes do not prove board propagation. The production desktop +was the held `071735d6` build; Android uses the clean beta 10 snapshot `e3ce041c`. Those commits have +identical relevant core/Canvas/Board code. Source tracing confirms `announceProjectFile` sends +this core-owned Board write as `workspaceExternalChange`. The active dirty canvas classifies +changed kanban metadata as a conflict and does not replace the projects-store Board; its conflict +strip is under the Board overlay. The existing `onServerChange` path already applies core-owned +updates while preserving live Flow state. Desktop's actual preload had deliberately made +`workspace.onServerChange` a no-op under the old server-only assumption. A core-channel-only +change would repair Server Edition but leave Desktop unaffected. Commit `ec12ea9a` includes +both the existing server-change announcement and real Desktop IPC subscription with unsubscribe. +The same owned paired profile was then restarted with the repaired production `ec12ea9a` build; +the actual no-reload phone/desktop move/add/remove/create-label checks below pass. + +**Source verification:** nine affected Vitest suites (133 tests), full `npm run typecheck` and +strict TypeScript checking of the acceptance test pass. Four isolated source mutants are caught, +including the original Desktop preload no-op. `src/core/workspace-store.kanban.test.ts` verifies +the own-write channel; `src/preload/index.test.ts` verifies payload delivery, channel separation +and exact unsubscribe. `test/acceptance/phone-board-updates.test.ts` uses the actual Desktop preload +and Server WebSocket adapters, production change planners and mounted real `KanbanView` for local +and SSH projects, including default Board seeding and preservation of unsaved canvas movement on +the next ordinary save. This does not replace physical rebuilt-desktop verification. + +**Private evidence:** the owned fixture's proof directory contains +`release-qa-board-snapshot-1791110071897978622-f0b5c9.json` (persisted In Progress) and +`release-qa-board-dom-1791110071897952231-481e0f.json` plus +`release-qa-board-dom-1791110131369275364-a2e18b.json` (mounted Ungrouped, matchesPersisted=false, +same page time origin). Native mobile proof is in +`.nodeterm/android-beta-build-9/checklist-20261004/beta10-board-after-move-portrait` UI receipts. +These observations preserve the original held-build failure separately from the repaired result. + +**Rebuilt physical verification:** the actual beta 10 phone moves the owned card to Ungrouped, +removes its blue label, re-adds that label, and creates/applies a second blue label. Each action +updates persisted state and the already mounted production desktop Board. Baseline +`release-qa-board-dom-1791111329350981266-9c35ac.json` and resulting DOM receipts +`release-qa-board-dom-1791111393296743731-3f0e0a.json`, +`release-qa-board-dom-1791111472591205070-dde430.json` and +`release-qa-board-dom-1791111663350183645-51ca7c.json` all match persisted state and share page time +origin `1791111312961.1`; no reload is performed. Matching native result/UI receipts are +`beta10-a95-rebuilt-first-move-result.json`, `beta10-a95-label-removal-result.json` and +`beta10-a95-label-readd-create-result.json` under the Oct4 checklist directory. This completes +item 36. Including item 49 Settings evidence, the current tally is 10 Pass / 22 Partial / 32 Pending. It verifies the owned production fixture, +without deployment to the user's regular desktop or a new Android APK. +Exact phone End also removes the adopted card from the mounted Board with the same page origin +and matching persisted state (`release-qa-board-dom-1791111853036062170-ed2205.json`); the original +producer terminal is preserved. This does not complete Wake/Refresh repaint checks in item 35. +This is an existing remote-action behavior shared by phone clients; @eneskirca should verify +iOS-triggered board changes after the repair. No external verb, payload or project-file format changes. + +**Relay creation, Rename and exact End verified; Refresh dispatch observed (2026-10-04).** On +beta 10, Sessions → New session → Terminal (shell) → Start creates and opens one actual +canvas-registered shell in the exact private project folder. Native input and pwd reach its +scoped bash pane. Rename updates the mounted desktop title without reload. Sessions Refresh +rediscovers both owned rows; the actual owned session-menu Refresh emits exactly one node-refresh +IPC, preserving pane identity and page time origin. Its repaint effect remains unverified. +Exact native End then removes only the adopted node/pane/PID birth; the original producer +terminal remains unchanged. The mounted Board also removes that card without reload. +Item 32's relay plain-shell variant passes; managed-account/cellular and other required variants +remain pending. Item 35 stays Partial for missing Wake and Refresh repaint evidence; item 36 +passes actual repaired-desktop move/remove/re-add/create-label checks (A95). Earlier first-round rotation restoration is historical; final restoration remains pending. The tally is 10 Pass / 22 Partial / 32 Pending; no full release/device pass +or final fixture/service cleanup is claimed. + +**Settings visible flow passes; item 49 remains Partial (A44, 2026-10-04).** On installed beta 10, +a temporary phone name and safe HTTPS loopback API persist after actual system Back and reopen. +An invalid HTTP address shows its field error after keyboard dismissal and a visually reviewed rejection toast; reopening +retains the prior valid HTTPS value. Reset to default, system Back and reopen restores the built-in +API; the original phone name/API are then restored and verified. Unedited leave/reopen preserves +the visible values. Internal preference-key absence and absence of writes were not physically +inspected on this nondebuggable app; existing `SettingsLeaveTest` behavior/source guards cover +those rules. The strict device ledger therefore marks item 49 Partial, giving 10 Pass / +22 Partial / 32 Pending. Native proof includes `beta10-settings-phase1-result.json`, +`beta10-settings-phase2-result.json` and `beta10-settings-toast-visual-review.json` in the Oct4 +checklist directory. + +**Phone system-theme compatibility passes (item 51, 2026-10-04).** On the installed beta 10 +Pixel, Computers and Settings native screens remain readable under system dark and light modes, +including status/navigation bars; four captured PNGs have a matching visual-review receipt. +The app retains its fixed dark palette in both modes; an automatic light app palette is not claimed. +Original system night mode `yes` is restored and verified. Tablet/foldable is conditional SKIP +because neither is available. Proof: `beta10-system-theme-result.json`, +`beta10-system-theme-restored.json` and `beta10-system-theme-visual-review.json` in the Oct4 checklist +directory. Item 51 passes, giving the current 10 Pass / 22 Partial / 32 Pending ledger. + +**Final phone cleanup and background watch remain pending (2026-10-04).** The intended Pixel's +wireless ADB endpoint is no longer reachable; its current endpoint has been requested. The guard +refused before the final phone Forget and portrait-lock restoration, so neither action is claimed. +Earlier rotation restoration belongs to the first test round; current final restoration is pending. +The isolated empty SSH service is stopped. Phone name/API and system night mode `yes` restoration +are verified. No natural-periodic background Done watch has started and no result is claimed. +The remaining phone cleanup and notification checks can continue when the correct Pixel returns. + +## A96 + +**Sessions have no local search (2026-10-04).** + +- Severity: **low**; effort: small; area: phone UX; kind: feature gap +- Status: **fixed in `f73633fb`; full candidate-source checks pass, physical follow-up pending**. +- Locations: `SessionsTab.kt`, protocol `model/SessionSearch.kt` and `SessionSearchTest.kt`. + +**Before:** the installed beta 10 source `e3ce041c` lists every open project's sessions in status +buckets; finding a named session or folder requires scrolling. There is no query field or filter. +**Implementation:** a native Search sessions field uses trimmed, case-insensitive literal matching +against the displayed session name, visible agent label, project name/folder and node folder. +Project matches retain the group; session matches retain only those original rows. Project order, +status buckets, actions and the unavailable-New-session note remain intact. Loading, a completed +empty host and no search matches have distinct labels. The host/tab's existing A43 saved-state +holders preserve the query across terminal navigation and recreation; Clear search is labelled. + +**Bounded verification:** all 12 actual `SessionSearchTest` JVM methods pass using cached +Kotlin 2.2/JDK 21 compilation: ten behavior tests and two native wiring guards. Twenty-one +isolated-copy mutants are caught by assertions; restored tests pass and production inputs are +unchanged. This is not a Gradle/Compose runtime or physical search result. +Private proof: `/tmp/nodeterm-session-search-verify/results.json`. +The subsequently coordinated actual eight-class focused Gradle run passes +(`precommit-focused-protocol-2.log`), and the final callback-aware offline app compile passes +(`precommit-app-compile-final.log`). Full candidate-source protocol/app gates subsequently pass +at `314105a4`, as recorded in the delivery receipt below. Physical search is not verified. + +**Follow-up:** add search/restoration checks alongside existing item 10's session-grouping checks, +without replacing that item's required route/status evidence. New phone touch/keyboard proof is +pending. No desktop/server host contract changes; the iOS search UI is separate UX work. + +## A97 + +**All computers does not refresh live while visible (A55 residual, 2026-10-04).** + +- Severity: **low**; effort: small; area: parity; kind: freshness gap +- Status: **fixed in `8443e71e`; full candidate-source and mutation checks pass, physical results pending**. +- Locations: `AllComputersScreen.kt`, `conn/ConnectionManager.kt`, protocol + `host/ForegroundRefresh.kt`, `host/ConnectionUsers.kt` and `ForegroundRefreshTest.kt`. + +**Before:** the merged screen in `e3ce041c` refreshes each paired computer once on START and on +explicit Refresh/Try again or an answer. Its Inbox and Usage can remain stale while open; the +eight-second watcher is owned only by individual host/terminal screens. This is separate from +the already-fixed merged-feed layout in A55. +**Implementation:** each paired host has a keyed STARTED watcher, using AUTO initially and on +later ticks. Denied/unanswered approval holds remain in place; only that host's Try again requests +USER approval. The initial UI task yields so on-screen Inbox registration precedes listing and +notification announcement. STOP/removal cancels that host's watcher. The last All-computers +watcher requests closure only after in-flight users finish; a newer watcher supersedes an old +deferred close. Individual host opening retains USER-first behavior and its quick-return policy. +One per-host serial operation covers connect, listing, snapshot publication and announcement, +including manual, pushed and polled refreshes. Pushed changes and delayed reconnect jobs are +children of the current watcher, including while delayed or queued behind that serial operation. +STOP cancels them; cancellation/current-connection guards prevent an old job from reopening a +hidden host or moving to a replacement watcher. The poll job is assigned before its lazy start. + +**Verification:** `ForegroundRefreshTest` defines 19 methods (18 behavior, one native +wiring), and the focused ownership run also includes six `ConnectionUsersTest` methods. They +cover cadence, trigger gates, cancellation, ownership, serialized relisting, host removal and +watcher-owned pushed/delayed work. Actual cached Kotlin 2.2/JDK 21 control/restored runs pass all +25 methods. Thirty-one non-equivalent isolated-copy mutants are assertion-killed: 17 behavior +and 14 native-wiring mutants. Ten source hashes remain unchanged and bind the receipt to +`8443e71ee872db46da68380a9db1ab05748818d2`; an equivalent redundant-guard mutation is excluded. +Private proof: `/tmp/nodeterm-all-computers-refresh-verify/results.json`. The earlier +eight-class focused Gradle run passes 134 methods with zero failures, errors or skips; that run +preceded final assertion-cleanup additions. Full candidate-source protocol/app checks +subsequently pass at `314105a4`, including these final assertions. +Physical item 60 multi-computer freshness, notification suppression and lifecycle +checks remain pending; no device-ledger promotion follows the source implementation. + +## A98 + +**Captured terminal output cannot be searched (2026-10-04).** + +- Severity: **low**; effort: small; area: terminal UX; kind: feature gap +- Status: **fixed in `1b1872f1`; full candidate-source checks pass, physical follow-up pending**. +- Locations: `TerminalScreen.kt` (`CopySheet`/Find chip), protocol `model/TerminalCopy.kt` and + `TerminalSearchTest.kt`. + +**Before:** `e3ce041c` already offers snapshot lines, link actions, line/range selection, Copy and +Share, but no text search. **Implementation:** Find opens that same local captured-output sheet. +A native query field highlights literal case-insensitive occurrences and moves Previous/Next +through matches with wrapping navigation. UTF-16/exclusive offsets match Compose spans; rows and +selected line identities stay unchanged. Query changes reset the cursor, Clear removes the query, +and no-match/truncated counts are explicit. Matching is capped at 2,000 occurrences, with +truncation reported only when another match exists. Whitespace in a nonblank query is significant. +This searches the existing captured snapshot, including joined soft wraps; it does not fetch or +search all remote tmux history, and no query is typed into the pane. + +**Bounded verification:** all nine actual `TerminalSearchTest` JVM methods pass and 13 +isolated-copy mutants are caught. Tests cover literal punctuation, +Unicode offsets, empty/long queries, navigation, the exact cap, original copy order and native +wiring. The coordinated actual eight-class focused Gradle run and final callback-aware offline +app compile pass (`precommit-focused-protocol-2.log`, `precommit-app-compile-final.log`). +Full candidate-source protocol/app checks subsequently pass at `314105a4`; physical results +remain pending. + +**Follow-up:** record the extra Find/query/selection checks beside existing Copy-sheet item 56, +and keyboard-open layout/closing checks beside item 50. Preserve all of those items' original +requirements and current statuses. No external protocol change; iOS UX parity can be considered +separately by @eneskirca. + +## A99 + +**SSH host-key discovery ignores recursive external Includes (A49 residual, 2026-10-04).** + +- Severity: **low**; effort: small; area: security; kind: discovery gap +- Status: **fixed in `e07071e9`; full candidate-source checks pass, physical included-key pairing pending**. +- Locations: `src/main/ssh-host-keys.ts`/`.test.ts`, + `android/protocol/src/test/interop/host-fixture.ts` and `PairingInteropTest.kt`. + +**Before:** `e3ce041c` discovers standard public-key names and direct HostKey entries in the +readable main config/drop-ins. A served key named only through an external/nested Include can be +absent from the sealed pairing anchors; the phone correctly refuses that first SSH key, and +pairing again repeats the incomplete discovery. +**Implementation:** readable Includes are followed recursively, including quoted/multiple paths +and lexically expanded glob components. Relative Includes resolve under the configuration root, +including when the including file is external. Canonical paths stop cycles; discovery has +depth/file/byte/directory/glob/public-file budgets, and nonregular/oversized/unreadable inputs are +skipped. Reads are bounded and use nonblocking/no-follow plus opened-inode checks. Public-key +fingerprinting retains the public-target guard; private HostKey paths named by the config are +excluded from Include reads. Relative HostKey values remain unsupported; no new trust override +or weakening of pin/anchor checks is introduced. +HostKey paths are deduplicated per file/across configs, with at most 256 distinct configured +paths canonicalized. Exhausting that budget stops further Include traversal so unresolved +private-key aliases cannot become later configuration reads. + +**Source verification:** the actual Linux Vitest control/restored runs pass all 19 tests, +including a real FIFO replacement boundary. Sixteen isolated-copy source mutants are caught by +assertions, including repeated/unbounded key canonicalization and continuing after key overflow. +Proof: `/tmp/nodeterm-ssh-include-root-verify/results.json`; the recorded inputs remain unchanged. +Desktop regressions cover recursive/relative Includes, glob ordering, cycles, budgets and read +boundaries. The Android fixture and new `PairingInteropTest` case use +scratch configuration/public keys with the actual production pairing service: the returned +sealed anchors are stored and used for an actual fixture SSH authentication. The actual +eight-class focused Android Gradle run and final offline app compile pass +(`precommit-focused-protocol-2.log`, `precommit-app-compile-final.log`). Full candidate-source +protocol/app and desktop checks subsequently pass at `314105a4`. +Physical included-key pairing is an item 11 follow-up, with the existing 64-item ledger unchanged. +The pairing field and SSH-visible contracts are unchanged; @eneskirca should check that iOS uses +the supplied anchors and presents the existing refusal/recovery messages consistently. + +**Current delivery boundary:** installed beta 10 / code 11 and its 10 Pass / 22 Partial / +32 Pending ledger remain the device checkpoint. These four source additions are not yet +physically verified. Private beta 11 / code 12 is now **prepared, not installed**, from clean +source `314105a435d18e719f53d49bb292d0f3e965ad4a`. The local AGP release completes in 51.78 +seconds, with R8 keeps passing. Candidate-source required gates report **730 protocol tests in +70 suites**, zero failures/errors/skips, offline app compile, **17 affected Vitest suites / +297 tests** and full desktop TypeScript passing. Proof: +`.nodeterm/android-beta-build-11/build-source-3/required-gates.json`. The existing layout source +pin in `LegRoutingTest` is corrected in `314105a4` to reflect the weighted list below Search; +that follow-up changes the test assertion, not product behavior. Across A96–A99, **81 +non-equivalent isolated mutants** are caught (21 + 31 + 13 + 16). + +**Independent artifact review passes:** candidate APK SHA-256 +`ae052a8a02567c42576012309c5de059827d8b9ff28a9972673af0440d977341` is verified against its +metadata and build/R8 provenance. Official SDK 36 and 37 both verify v2/v3 signatures, one +expected retained signer, package/version, minSdk 26/targetSdk 35, nondebuggable metadata and +16 KB ZIP alignment; all four native libraries satisfy 16 KB ELF alignment. Signing preserves +all 149 unsigned ZIP payloads and adds only the three v1 signature entries. Terminal assets +and native libraries are byte-identical to beta 10; DEX, version/signature/optimization metadata +and an R8-mapped service-loader descriptor change as expected. Service-loader entries match +the actual R8 mapping. Receipt: `.nodeterm/android-beta-build-11/artifact-review.json`; +private APK: `.nodeterm/android-beta-11/nodeterm-android-0.1.0-beta.11.apk`. +No installation, new physical PASS or new CI result is claimed by this preparation. Each +requested push still requires green exact-head workflow verification; current phone cleanup, +rotation restoration and background watcher checks remain pending. + +## A100 + +**Retained host history search** + +Source change: `2f693d83, 97b8a9a4, 4f1985f5`. + +Find previously searched only phone-captured output. It now searches retained host history on the attached generation/exact pane, with literal case-sensitive queries, original line numbers and explicit capture/result bounds. Search does not change copy mode or input. Old backends refuse without replacement. Query/stream/lifecycle fences discard stale answers. Actual producer/consumer and retained output-order tests pass; physical Find flow remains pending. + +## A101 + +**Interrupted typed session-host reads reconnect** + +Source change: `fbaa535d`. + +An EPIPE in the existing socket suite also reproduced on the earlier baseline. Bounded reconnect/resend now allows only named typed reads after EPIPE/ECONNRESET. Sent writes, deadlines, host refusals and unknown errors do not replay. Actual socket tests and three isolated policy mutants pass. + +## A102 + +**Cold relay attach retains trusted project launch settings** + +Source change: `e584586f`. + +Cold phone attaches previously omitted project env/shell. The host prepares trust-aware settings without spawning, resolves saved project/account/agent ownership ahead of phone hints, rechecks warm races, then commits reply/snapshot/attach synchronously. Existing warm sessions retain launch facts. Real shared launch builders and 22 mutations verify the source; actual fixture discovery is isolated from host tmux. Physical/live launches remain pending. + +## A103 + +**Agent approval policy survives launch, resume and wake** + +Source change: `91e6a3f5`. + +Non-Claude launch/resume/wake previously dropped project approval policy. Android now matches measured desktop agent dialects and the actual host Codex vocabulary; only Claude uses its own auto capability gate. Unsupported modes retain CLI defaults. Eight actual mirror/desktop-builder fixtures and six mutations pass; a live agent/device matrix remains pending. + +## A104 + +**Offscreen Sleeping nodes can wake without switching projects** + +Source change: `2d6cb2cd`. + +The renderer-only mounted-node nudge left offscreen/closed-project sessions Sleeping. The desktop now resolves unique saved ownership and actual live/released session proof across awaits, preserving Pause, exit/process/generation guards and original SSH routing. A typed persistent-backend wake is additive; an old backend refuses without restart. Actual socket/tmux regressions and 22 mutations pass. Windows runtime and physical inactive-project/lifecycle checks remain open. + +## A105 + +**Earlier Desktop application verified (2026-10-07, source `41406fef`):** Real Claude 2.1.292 uses genuine +captured node routing in a sibling namespace and the unchanged managed hook. The shipped Desktop +consumer applies one original `localSettings` rule: nine hooks, one consumed/removed hold, two +Bash executions without a second hold, and the exact CLI-saved rule; **8.648 seconds / $0.0166058**. +Result `nodeterm-new-phone-fixture-h7nho19u/home/qa-project/real-cli-a105-44b6a670/proof/result.json` +hashes to `741b0f5a73522ec76bf29ebeab7a0cf43b9c2b19a47da0f022ecc0369356c228`; independent review +`nodeterm-new-phone-fixture-h7nho19u/proof/root-a105-application-review.json` hashes to +`ee9e1fd9dc3fc191b1d6ede108347252d29265dc4222143bb7d4a60f97c6d434`. At that earlier checkpoint, Android input was unverified. + +**Physical Android application verified (`e06b5547` / beta 19):** The normal Always allow dialog +selects one original `localSettings` rule. Real sibling Claude consumes one 36.814-second hold and +executes two Bash calls without a second hold (nine hooks, 45.982 seconds total), saving the exact +rule itself. [Actual result and independent review](android.md#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07) +retain the original-node/request correlation. Rule persistence after CLI restart, production-pane-child +launch and wider scope remain pending; A106's scoped pass is below. The original 10/22/32 ledger is unchanged. + +A separate private FD/TTY component calibration (`a105-toy-fd-actual-822fkn84`) fails before any +provider action. It establishes no production-pane-child launch or restart acceptance and is not +a new product finding; the pending limits above remain. + +**Always allow uses original concrete hook rule scopes** + +Source change: `db4abccf, 7c206ec5`. + +A56 is now implemented without its unsafe proposed blind digit. Eligible original addRules suggestions carry exact tool/content/destination and UI confirmation; the guarded v2 reply uses updatedPermissions. Android and Desktop/Server offer the same original scopes. Gone/replaced/unsupported requests never type keys. Actual shipped managed-shell consumer and Kotlin/TypeScript fixtures pass; the bounded Desktop and Android rule cases above are verified, while restart/wider persistence remain open. iOS adoption is owed to @eneskirca. + +## A106 + +**Physical application verified (`e06b5547` / beta 19):** One original held AskUserQuestion +preserves both questions; normal phone Send answers chooses Cobalt (single) and nonadjacent +Lint/Docs (multi). The unchanged hook's `updatedInput`, actual PostToolUse/Stop and final PTY +output match. [Result and independent review](android.md#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07) +record explicit owned provider stop/reap after completion; wider question/lifecycle scope remains pending. + +**Complete held Claude questions have deterministic answers** + +Source change: `db4abccf`. + +A57 display-only work is extended to all supported held questions. Parent PreToolUse AskUserQuestion requests publish full question schemas, submit every selected index, preserve original input and derive exact answer labels through hook JSON. Held v2 cards omit legacy digits for old phones; unheld multi-select remains Open session. Request/card/file fences and consumed-hook POST govern settlement. The 52 shared hook/Android mutants cover A105/A106 together; the original two-question phone/CLI case above passes, while wider scope remains open. + +## A107 + +**Typed source control works on direct SSH** + +Source change: `489c30a8`. + +The existing eight Git verbs now execute over POSIX SSH inside physically admitted local/driven roots and repository roots, with quoted argv/literal paths, NUL-delimited parsing, bounded output and confirmed status. Git capability does not depend on an actions-service advertisement or a relay leg. Third-machine projects refuse. Unknown writes retire the connection with no replay. Upstream fallback requires the exact native exit-128 missing-upstream diagnostic for the pre-dispatch branch. Real SSH Git tests include hostile filenames, symlink jail escape, hooks/signatures, rejected pushes and transport uncertainty. Twenty-six pure and five real transport/routing mutants are caught; physical Source Control remains pending. + +## A108 + +**Owned SSH Board and Desktop session actions** + +Source change: `d716138c`, `585e726f`. + +At the A108 checkpoint, Desktop/Server exposes bounded private typed request/response files under the exact selected userData profile, with fresh instance/host-time advertisement, ownership and nonce guards. Board operations use the actual WorkspaceStore save queue and change broadcasts. Desktop offers delivery-only wake/refresh/rename nudges; Server offers Board only. Duplicate requests do not execute twice inside their immutable retry window; unknown results never replay/fallback. Another desktop's unowned driven projects refuse. Canvas cold New was outside the A108 service scope. A111 now adds a host-owned launch API on current enabled local Linux/macOS tmux hosts, including Server; its physical checks remain pending. Actual filesystem/FIFO, Desktop wiring and headless Server checks pass (33 tests); 29 native and 29 Android client mutation variants catch regressions. Real Node/Kotlin/generated-shell interop passes; the full merged Gradle gate verifies MINA and bundle coverage. Physical service flows remain pending. + +## A109 + +**Three-digit findings retain device coverage** + +Source change: `4e2f877d`. + +The documentation scanner recognized only two-digit finding IDs, silently ignoring continuation IDs from A100. Both heading and reference scanners now accept full IDs with at least two digits and reject malformed suffixes. The actual regression and restored control pass; reverting either production regex is caught by an assertion. Two mutants are killed. + +## A110 + +**SSH integration fixtures retain their own profiles and actual permissions** + +Test changes: `8c57016c`, `b2e41255`. + +The first merged protocol run exposed shared-home pollution: the new file-service tests reused +one class fixture and changed the workspace later transport tests expected. Each service method +now owns and closes its own SSH server, private home/profile, tmux root and command observer. +Existing browse, third-machine refusal and uncertainty assertions are preserved. A real MINA +mutation restores the shared home: the first service succeeds, later producers fail with EEXIST, +and the unchanged browse and NeedsRelay assertions catch the corrupted fixture. Control and +restored runs pass all six real methods. Nine additional Inbox mutants verify host/event admission, +busy state, cancellation and finally retirement; they extend the stale merged wiring assertion. + +The affected native suite also exposed a test-created mode narrowed by inherited umask `0077`. +Explicit chmod makes the unsafe service directory actually `0755` before asserting Unsafe refusal. +All 30 tests pass under `0077` and `0022` in both control and restored runs; removing chmod triggers +the exact Vitest rejection assertion. The assertion still requires Unsafe refusal. Full merged gates pass +815 protocol tests / 83 suites and 1,314 affected Vitest tests / 53 files, with zero failures and only +the ten named existing Windows/macOS runtime skips on Linux; app and TypeScript checking pass. + +**Prepared private beta 12 (2026-10-04).** `0.1.0-beta.12` / code `13`, clean built source +`b2e41255d8b2cf7bb342b78b7f686b8b0191879e`, includes A100–A110 and the earlier beta-11 additions. Full offline protocol +checks pass **815 tests / 83 suites**, with zero failures, errors or skips. +Offline app compilation, **1314 affected Vitest tests / 53 files** and full TypeScript +checking pass. The ten existing Windows/macOS runtime cases are explicitly skipped on this Linux +host; no additional skip is accepted. The minified offline release build passes in +**52.47 seconds**, with retained private signer, R8/runtime keeps and source/hash provenance. +Independent SDK 36/37 signature, nondebuggable manifest, payload and 16-KB ZIP/native checks pass. +The private support archive retains **236 isolated mutation variants** with passing controls and +restored runs: 228 assertion cases, six named executable-contract failures and two bounded timeouts. +Historical source hashes remain attached to their own receipts; this count does not claim that +all earlier worktrees were identical to the final merged source. Full merged release gates pass. +APK SHA-256: `d5a350a1f7c6f05f1ab476f25cbe58441eb666d61d1a4b62610a3faa2e058e1f`. Artifact: +`.nodeterm/android-beta-12/nodeterm-android-0.1.0-beta.12.apk`; build and mutation proof: +`.nodeterm/android-beta-build-12/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone checks are paused, with **10 Pass / 22 Partial / 32 Pending**; +live Claude rule/question application and the new physical feature matrix remain unverified. +Each requested push still needs its own exact-head green Android workflow. No PR opened; A68 +remains deferred until a PR is requested. + +**Prepared private beta 14 (2026-10-04).** `0.1.0-beta.14` / code `15`, clean built source +`facbbd0384bb5b0ee91656f364793a12f8f41eb1`, adds A113 truthful revoke outcomes, A114 dictation language, A115 exact host/session +retirement and A116 saved pairing/LAN adapter choice. Full offline protocol checks pass +**890 tests / 92 suites**, with zero failures, errors or skips. +Offline app compilation, **1628 affected Vitest tests / 71 files** and full +TypeScript checking pass. Only the ten existing Windows/macOS runtime cases are skipped on Linux. +The minified offline release build passes in **47.96 seconds** with the retained private +signer and R8/runtime keeps. Independent SDK 36/37 signature, nondebuggable manifest, payload/ +provenance and 16-KB ZIP/native checks pass. + +The private archive records **158 assertion-caught isolated mutation variants**, +with passing controls/restored runs and each receipt's source bindings. One additional redundant +retired-guard variant survives equivalently and is recorded separately, never counted as caught. +Historical/intermediate bindings stay separate; full merged gates verify the release source. +Actual local HTTP and producer→Android tests use synthetic OS/key facts; HostStore tests use +in-memory Android interfaces. Native wiring source guards and cached compilation do not prove +physical recognition, VPN reachability, relay revocation or replacement behavior. Earlier beta +proof remains historical. APK SHA-256: `3c75912a5b7bb561fa9a0720be5cb11be75e1672f98379e4745871bf2729f8f6`. Artifact: +`.nodeterm/android-beta-14/nodeterm-android-0.1.0-beta.14.apk`; private proof: +`.nodeterm/android-beta-build-14/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone testing is paused; the ledger stays **10 Pass / 22 Partial / +32 Pending**. No PR opened; A68 stays deferred. Each push requires its own exact-head green +Android workflow. At this APK source checkpoint A13 direct-SSH Desktop detachment, full legacy +identity association and A25/A93 hosted backend dependencies remained open. The later A13 host +follow-up below closes the coattachment source gap; its physical checks remain pending. + +**Historical prepared private beta 13 (2026-10-04).** `0.1.0-beta.13` / code `14`, clean built source +`ba64f6289c0f552b85bfc17f99aee879aaf45975`, includes A111 managed SSH New and A112 producer-input coverage, +along with the beta-12 changes. Full offline protocol checks pass **845 tests / 87 suites**, +with zero failures, errors or skips. Offline app compilation, **1433 affected Vitest tests / +60 files** and full TypeScript checking pass; the ten existing Windows/macOS runtime cases +are explicitly skipped on Linux. The minified offline release build passes in **47.77 +seconds** with the retained private signer and R8/runtime keeps. Independent SDK 36/37 +signature, nondebuggable manifest, payload/provenance and 16-KB ZIP/native checks pass. +The new managed-creation and input-coverage regressions catch **148 isolated behavioral mutation +variants**, with passing controls/restored runs and per-receipt source hashes. Receipts distinguish +assertion failures from the named runtime contract failure; earlier beta-12 proof remains separate. +An isolated native node-pty/Electron/tmux fixture verifies host cwd/account/hooks/env/policy, +one launch submission and uncertainty fences. It uses a fixture CLI; real Claude startup, +macOS native creation and physical power-loss durability are unverified. +APK SHA-256: `faf9e508fa82355f640512ec1861ba886d0e48b8aef3e724edc6435c9f8c134b`. Artifact: +`.nodeterm/android-beta-13/nodeterm-android-0.1.0-beta.13.apk`; build and mutation proof: +`.nodeterm/android-beta-build-13/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone testing is paused; **10 Pass / 22 Partial / 32 Pending** +remains the device ledger. Managed New and the other new source flows require physical checks. +Each push requires its own exact-head green Android workflow; no PR opened, and A68 stays deferred. + +## A111 + +**Canvas New over direct SSH needs host-owned creation, not registration metadata (2026-10-04).** + +A108's private actions service supplies Board/node metadata actions but cannot safely recreate a +cold managed pane by registering a node alone. Phone-built commands would duplicate the desktop's +account, trust, hook and launch policy; ordinary SSH attach must continue refusing missing panes. + +The additive `sessions.createManagedV1` action resolves actual open local project/settings/account +facts through the host planner. An enabled Linux/macOS tmux backend exclusively creates a new +host-minted session, confirms one exact pane/PID/kernel birth and records runtime ownership. The +WorkspaceStore queue saves portable cwd and local shell, preserving pending intent until one +attested host submission is confirmed. Its private journal syncs file and directory phases before +side effects. Expanded launch text stays in private state/stdin, never shared project/receipt. +Failures expose an inert manual recovery notice; restart/uncertain/different-choice requests never +replay a launch or grant ownership from disk. Removed/pending accounts and changed cwd are refused +again at synchronous spawn, while ordinary renderer Home fallback stays intact. + +Android saves the exact UUID/nonce/instance request before dispatch and public receipts until an +actual guarded SSH viewer is confirmed. The first attach pins selected profile/service, whole +single-pane session, creation/PID/birth marker, and fresh SSH tty; it is attach-only. Closing the +dialog/backgrounding cannot resend or navigate a stale screen. Stale proof requires explicit +inspection/discard; no automatic relay fallback or replacement creation. Older hosts retain the +relay New flow. Windows/non-tmux and third-machine/driven project creation are unsupported V1. +Managed canvas End remains relay-only so terminal termination also removes the canvas node; +SSH viewer close leaves it running. The separate phone-owned plain terminal End is unchanged. + +Actual isolated native node-pty/Electron and tmux creation with a fixture agent passes; this proves +one submission with the host cwd/account/hooks/env/policy, not real Claude startup. Protocol, +producer interop, mutation and minified beta-13 local release gates pass. The Pixel remains on +beta 10 and the 64-row ledger remains 10 Pass / 22 Partial / 32 Pending. iOS @eneskirca needs the +new advertised action and durable attach-only receipt contract; no external message or PR opened. + +## A112 + +**The managed producer exposes missing session-host CI and local incremental coverage (2026-10-04).** + +A111's actual PtyManager fixture imports `src/session-host/**`; the esbuild-metafile regression +found those repository inputs absent from both Android workflow path filters. Gradle's declared +external source input directories also omitted them, so a source edit could leave local protocol +tests up to date. Both push/pull_request filters and Gradle inputs now include the directory. +Actual fixture graph/declared-input regressions cover this addition; removing either trigger +filter or the Gradle input fails its isolated mutation check. The actual producer/consumer +fixture and coverage guards pass, catching nine isolated variants. A68's PR-stage trigger +restriction stays deferred; each push still requires exact-head green Android CI. + +## A113 + +**A legacy pairing can report a successful revoke without proof that relay access was removed (2026-10-04).** + +Fixed in `349b791a`. Desktop revocation now reports `unconfirmed` for a saved pairing with no +recorded relay key or no available revoker, and `retained` when another saved pairing still +authorizes that exact key. Settings shows these outcomes separately from local SSH-key removal +and Pro expiry. An absent relay outcome from an older main process also remains unconfirmed; +the confirmation dialog no longer promises removal of every connection. Confirmed last-key +unpin/session closure remains required for the clean relay receipt. Seven affected main/renderer +suites pass 111 tests; eleven isolated assertion mutants are caught with passing control/restored +runs. The source-bound mutation fixture uses a temporary-home service boundary and mounted React. +These additive local Desktop IPC outcomes do not change the phone wire contract. Full legacy +identity association/migration and physical relay revocation remain open; no unrelated approved +key is guessed or removed. + +## A114 + +**Android dictation has no language choice (A59 follow-up, 2026-10-04).** + +Fixed in `6b989e59`. Settings offers a searchable language/region picker using human-readable +locale names and an explicit System default. The phone-wide preference is separate from host +identities and is frozen when a dictation starts. System default omits the native language extra; +a chosen language sends its canonical BCP47 tag. Partial/final results still fill only the draft, +and microphone, cancellation and lifecycle rules remain unchanged. The bounded catalogue lists +locales, not installed speech models: native unsupported/unavailable errors stay visible. +Three focused protocol classes pass 39 methods, offline app compilation passes, and eighteen +isolated compiled mutants fail assertions with passing control/restored runs. Native intent/UI +wiring is covered by source guards; the cached Kotlin/Compose compilation is separate evidence, +not a recognizer/device pass. Physical language availability, recognition and layout checks remain +pending. No host wire contract changes. + +## A115 + +**Re-pairing leaves local host preferences and lets retired connections publish late (2026-10-04).** + +Fixed in `0a71c028`. Replacing a paired record retires its route, relay approval, +managed-creation checkpoint and old token while preserving unrelated computers and same-ID preference +updates. Forget and re-pair retire the exact old connection objects; a shared session-admission +barrier covers local record/token publication, including incoming IDs with no previous key match. +No network await runs under that barrier. A failed local secret write retains the record, while its +old socket remains retired. This is a local monitor fence, not a crash-safe Keystore/preferences +transaction. + +Owner-bound lifetime leases cancel pending dials and prevent late pin, LAN, relay-token, approval, +notification or checkpoint publication into a successor, even when it reuses the ID and key. +Normal disconnect still permits reconnect and retains an uncertain creation receipt; permanent +retirement never reconnects. Both primary and side relay results are retained before cancellable +handoff and closed on cancellation. Quiet notification reach uses the exact session without a +reverse manager lookup. Closing local polling does not revoke an existing Desktop SAS consent. + +Private controls/restored runs execute 177 repository methods and 29 actual +production HostStore adapter cases against in-memory Android interfaces. 73 isolated variants +fail assertions. One additional redundant retired-guard experiment survives equivalently because +retirement also invalidates every admitted generation; it is recorded separately and is not counted +as caught. Native wiring remains source-guard evidence, with app compilation checked separately. +No phone, live transport, physical durability or full legacy Desktop identity migration pass is +claimed. No host wire contract changes. + +## A116 + +**Pairing can advertise a Docker/VPN address the phone cannot reach (A49/A74 follow-up, 2026-10-04).** + +Fixed in `a6b3e88b`. Desktop Settings → Phone lists current IPv4 adapters and an Automatic choice. +The saved selection is an adapter name, so each new QR and authenticated relay LAN report follows +its current address across DHCP. An unavailable explicit adapter prevents a new QR and omits the +report's dial address; it never silently substitutes another adapter. Automatic prefers a physical +adapter on POSIX, retains Windows' validated current route hint, and permits a virtual-only fallback. +The list describes current local addresses, not proof that the phone can reach them. Windows remains +relay-only; browser Server pairing/network controls deliberately remain unsupported. + +Both Settings and quick pairing acknowledge pending settings writes before starting. Changing the +choice hides/stops the old QR first, offers an explicit failed-save retry, and cannot restart after +its visible row is hidden. Real global-search rows work too; hiding that row retires only its own +listener, including while its parent hook remains mounted. Old replies/events cannot stop or update +a newer pairing view. The legacy exported picker helpers also use the shared policy. + +Isolated Desktop controls/restored runs pass 156 methods; one unrelated pre-existing worker-spawn +guard is explicitly filtered in that private sandbox and remains part of the full merged gate. +Root's actual local HTTP-listener controls/restored pass all six methods. Five producer→Android +methods call the real policy, QR builder and LAN reporter with a fake OS table/public-key directory, +then the existing Android parser and LAN-refresh policy. They prove paired-host identity/pin +preservation, not Keystore or approval publication. The three final proof groups catch 56 assertion +mutants (32 Desktop, six real HTTP, eighteen interop); preliminary receipts remain separate. +The existing wire shape is unchanged and the Android client needs no parser change. iOS @eneskirca +should adopt the existing authenticated `lan` field; it now reflects the saved Desktop adapter. +Physical QR/VPN/DHCP, search UI and multiadapter checks remain pending. No external message sent. + +## A117 + +**Relative private HostKey declarations can be opened by a configuration Include (2026-10-04).** + +A relative `HostKey` is still excluded from SSH anchors because discovery does not know the +daemon's working directory. Its normalized private basename now also excludes lexical and +canonical Include candidates before they are opened. Explicit public `.pub` declarations remain +readable, and relative declarations share the existing bounded declaration budget. This is a +conservative exclusion by name; it does not infer relative paths or discover unknown aliases. + +All 22 host-reader methods pass on this Linux host, including the existing real FIFO race. +Seven isolated reader mutations fail assertions with passing controls/restored runs. The real +pairing-service → Android regression seals only a disposable server's permitted key, preserves +the anchors in the phone record, accepts that real SSH server and refuses a second real server. +The original source and removal of the canonical exclusion both fail that regression; restored +source passes. Private-file inputs are synthetic config sentinels, never actual private keys. +Proof is retained in `.nodeterm/android-relative-hostkey-2026-10-04/`. + +The sealed field and Android production code are unchanged; the actual interop fixture is updated +with the producer. iOS @eneskirca should verify the existing anchor handling against the current +host. Prepared beta 14 remains valid client source and is not installed. Physical checks remain +pending; phone testing is paused and the ledger stays 10 Pass / 22 Partial / 32 Pending. + +## A118 + +**Eligible older pairings cannot associate their existing relay key with a saved device (2026-10-04).** + +The standing phone host now offers `pairing.relayKeyChallengeV1 {pairingId}` and +`pairing.relayKeyProofV1 {challengeId, signatureB64}` only after the normal relay approval gate. +A legacy pairing is eligible only when its exact host-minted UUID has one unambiguous, canonical +Ed25519 public key under the host's existing `nodeterm-ios-` authorized_keys attribution. +Missing, option-bearing, duplicate, malformed, replaced or unsafe files refuse proof; relative +identity guesses and device names are never used. + +Each connection holds at most one ephemeral challenge and one in-flight inspection. It binds +version, challenge UUID, pairing UUID, pinned host box key, authenticated peer box key, raw SSH +public key, random nonce and expiry. All fields use the fixed newline encoding in +`src/shared/relay-pairing-proof.ts` and Android `RelayPairingProof.Challenge.signingBytes`. +Both wall and monotonic host time enforce 60 seconds. Every proof attempt consumes the challenge +before awaiting publication, including malformed signatures; another session cannot use it. + +Android attempts the optional proof only after an approved first listing, using its existing +encrypted SSH seed. It validates the captured host, peer and SSH keys before signing, checks the +current record/lifetime around awaits, and never creates an identity, changes approval or tokens, +or retries an uncertain proof. Missing identity, unsupported older hosts and ordinary optional +errors preserve browsing; cancellation propagates. A socket closed during proof cannot be handed +off as connected, and a later network close after proved approval is not reported as Deny. + +The actual host pairing queue stages a private association, then rechecks the bounded exact +registry snapshot, attributed public key, approval, connection lifetime and expiry immediately +before synchronous atomic publication. Existing same-key association is idempotent; conflicts and +deleted/replaced entries refuse. This is a same-service queue, not a cross-process transaction. +Normal revocation can then unpin/cut the exact proven last key; a sibling pairing still authorizing +that key keeps the explicit retained outcome. + +Focused controls pass 87 host and 15 Android helper methods. Forty compiled host and 25 compiled +helper mutation variants fail assertions. Seven real producer→Kotlin encrypted interop methods +cover sealed legacy pairing, actual signature verification, exact publication/revoke, distinct and +shared-key siblings, wrong SSH identity, cross-session/consumed challenges, old-host fallback, +unapproved access and the close/callback ordering. Six additional interop variants fail assertions +with passing controls/restored. The public-pin adapter, human decision, OS address and unused PTY +are explicit fixture boundaries. Private evidence: `.nodeterm/android-legacy-relay-proof-2026-10-04/`. + +This is an additive host/Android contract. iOS @eneskirca needs the same retained-key signer and +exact encoding to repair eligible legacy records; old iOS remains usable. A phone without its +retained SSH seed, its matching attributed SSH public key in the host's authorized_keys or SSH support still needs re-pairing. This proof never +bypasses first SAS approval and does not solve hosted FCM or A93. New Android source needs a later +APK than beta 14; no new installation or physical/live transport pass is claimed. Phone testing +remains paused, beta 10/code 11 is last installed, and the ledger stays 10 Pass / 22 Partial / 32 Pending. + +## Prepared private beta 15 and verification checkpoint (2026-10-04) + +`0.1.0-beta.15` / code `16` is built from clean signed source `aa902d0a52ba8ccfa0be57e073c350e90cfad5d3` and includes +A118's optional retained-SSH legacy relay identity proof. The host must also run this source for +migration; older or unprovable hosts remain browsable. A117's relative private-key Include fix and +A13's coattachment fix are included in the host source. + +Required source checks pass: 912 protocol methods / 94 suites, zero failures/errors/skips; +offline app compile; 1,919 affected desktop methods with exactly ten known macOS/Windows skips; +full TypeScript checking. All five exact-source Android jobs pass: [source CI](https://github.com/CPlusPlus17/nodeterm/actions/runs/37228985208). +A118's focused proof remains 87 host and 15 helper methods, 40 + 25 compiled assertion-caught +mutants and seven actual producer→Kotlin methods with six additional assertion-caught variants. + +The local release build uses a fresh tracked Android-only snapshot, cached AGP 8.10.1/Kotlin 2.2.0/ +Gradle 8.14.3/JDK 21 and `--offline`. R8 runtime keeps and 56 independent artifact checks pass: +SDK 36/37 signatures and 16 KB alignment, retained signer, actual version/manifest/provenance, +service-loader mappings and unchanged terminal/native assets versus beta 10. +Private APK: `.nodeterm/android-beta-15/nodeterm-android-0.1.0-beta.15.apk`. +SHA-256: `c1e707c8a18555db7e01129b709d39db342128f3c54d8103d548cc177bc9abaf`. +Signer SHA-256: `c610c3a0b637a8005b6fd858587e8cbf5260622648bc1cd97c0de0ab5f146dbd`. Private build/review evidence: +`.nodeterm/android-beta-build-15/`. The APK, signer and proof remain outside source exports. + +Live A105/A106 application is still unverified. An isolated installed Claude 2.1.289 fixture uses +the actual managed hook server/script/writer with an in-memory CorePlatform. Its fresh-context, +read-only credential/config and fixed-tool guards pass 34 static checks and the exact offline +mount/auth gate. The first attempt hid the system DNS target and timed out before tools; a +separate corrected fixture exposes only that regular resolver file read-only. It reaches the +provider, but receives authentication HTTP 401 before any tool request. No application pass is +claimed. Original profile/source/binary/resolver metadata remains unchanged; raw diagnostics stay +private and no credential refresh/reset is attempted. After the existing CLI account is usable +for inference, rerun the isolated remembered-rule/full-question checks. This gives no physical, +renderer, SSH or hosted-relay proof. + +Phone testing remains paused. Beta 10/code 11 is last confirmed installed; beta 15 is prepared +and not installed. The ledger stays **10 Pass / 22 Partial / 32 Pending**. Next device work uses +this APK and current host source, checks saved key/pin survival and the expanded checklist, +then restores rotation and removes only owned QA host state. A25/A93 still require hosted-backend +maintainers; iOS @eneskirca owes the new proof contract and earlier mirror/verb/v2-answer adoption. +No PR is opened; A68 stays deferred until requested PR preparation. + +## A118 JUnit registration follow-up (2026-10-04) + +The focused Kotlin runner exercised all 15 helper methods, but the initial Gradle/JUnit run +registered only 14: the outer-timeout coroutine test inferred an exception-valued return. +Its explicit `runBlocking` signature now lets JUnit discover it. Actual offline Gradle +controls register all 15 with no failures/errors/skips. Removing the signature compiles and +passes 14 methods, then fails the exact method-registration assertion; restoration passes 15. +The seven actual host/Android interop methods were already registered. Private evidence: +`.nodeterm/android-legacy-relay-proof-2026-10-04/junit-registration/`. + +The required local gate now checks the actual JUnit helper inventory and the outer-timeout +method explicitly. This correction changes only a protocol test and documentation after +beta 15's `aa902d0a` source; its shipped Android runtime inputs and private APK are unchanged. +The APK remains prepared and uninstalled, live Claude application remains unverified after +provider HTTP 401, and the physical ledger stays 10 Pass / 22 Partial / 32 Pending. + +## A119 + +**Explicit SSH profile folder (2026-10-04).** + +Add SSH server now accepts an optional absolute **Profile folder**. Existing computers can change +**SSH profile folder** in Settings → How to reach each computer. The saved phone-local choice +selects a custom Desktop/Server data directory ahead of all automatic discovery. Blank restores +discovery. A missing or invalid explicit choice reports an error and never selects another profile. +Board, project reads and all three managed-view stages use the same captured profile. Saving a +change invalidates pending connection work and clears old displayed rows while retaining uncertain +creation receipts; it never recreates a session. SSH host pins, pairing and relay credentials remain +unchanged. Paths are quoted literally and reject relative/dot components, controls and oversized input. + +Regression coverage uses actual Server config/platform/WorkspaceStore/mirror/actions producers, +a real private MINA SSH server and isolated tmux. The fixture supplies a plain-shell planner/native +create boundary; it does not run full Server boot, installed hooks or account probes. Native storage +coverage uses actual app classes with in-memory Android adapters. Ten protocol model methods, +three actual Server/SSH methods and the complete 62-method SSH/10-method workflow suites pass. +Four compiled SSH profile mutants fail their intended assertions. Final native control/restored +runs pass 19 cases and catch the retired-session mutation; seven earlier native variants have +separate historical source bindings. Private receipts: `.nodeterm/android-ssh-setup-2026-10-04/`. Physical setup/profile switching remains unverified; +phone testing is paused and the ledger stays **10 Pass / 22 Partial / 32 Pending**. At this source checkpoint, beta 15 was +the latest prepared APK and excluded A119. The beta-16 receipt below records its later packaging. +iOS implication for @eneskirca: offer an explicit saved SSH profile choice consistently for discovery +and managed attachment. No host verb or pairing payload changed. A25/A93 and A68 remain open; +no PR is opened. + +## A120 + +**One-time password SSH setup (2026-10-04).** + +Add SSH server keeps its public-key Copy/Share/manual command and adds **Set up with a password**. +The app inspects the SSH host key without authentication. Compare its fingerprint on the computer, +then explicitly confirm **I compared it — it matches** before entering the password. Password login +rechecks that exact key, installs only the retained phone public key, closes the setup connection, +and proves a separate pinned key-only login before saving the host. A changed key, refused password, +unsafe key-file target, uncertain command or failed key verification never saves a computer. +No setup write is automatically replayed. A lost acknowledgement may leave the public key installed; +use the normal key connection or check authorized_keys on the computer before starting a new attempt. + +The password is memory-only, absent from saved state/preferences, shell text and diagnostics. Leaving +or backgrounding setup cancels owned sockets and clears its password/fingerprint approval. JVM/library +transient copies cannot all be erased. Existing keys and restrictions are preserved; commented or +trailing key-like text is not an installed declaration. Symlink, nonregular, foreign-owned and hardlinked +targets refuse. These are bounded pathname preflight checks, not descriptor-atomic protection against +concurrent same-user replacement. Keyboard-interactive/MFA, password changes and Server installation +remain unsupported. Host pins, pairing/relay credentials and ordinary key-based connections retain +existing behavior. iOS implication for @eneskirca: confirm fingerprints before password auth and verify +fresh retained-key auth before saving; no host-service verb or pairing payload changed. + +Seventeen actual protocol methods pass against a private MINA SSH server and private POSIX HOME. +They cover fingerprint/auth order, other/restricted keys, uncertainty/no replay, both-stream output +limits, own timeout versus external cancellation and socket closure. Offline app compilation passes. +Seven isolated compiled semantic variants fail assertions, with 17 passing control/restored +methods and exact integrated source hashes. Private proof: `.nodeterm/android-ssh-setup-2026-10-04/password-final/`. +The full required gate verifies actual Gradle/JUnit method registration too. This is source/fixture proof. +Actual Android setup UX, ordinary PAM/account behavior and macOS runtime checks remain pending; +the controlled native OpenSSH receipt below adds wire/exec evidence. Phone testing stays paused, +with beta 10/code 11 last installed and **10 Pass / 22 Partial / 32 Pending**. Beta 15 excluded A119/A120; +the subsequent beta-16 receipt below includes both. A25/A93 require the hosted-backend maintainers; A68 stays deferred and no PR opens. + +## Prepared private beta 16 and verification checkpoint (2026-10-04) + +`0.1.0-beta.16` / code `17` is built from clean signed source +`2723845efac75cfcf2cd0a9fdfa11d9496699641`. It includes A119's explicit saved SSH profile folder and +A120's opt-in password enrollment, along with the earlier beta-15 changes. Missing explicit profiles +never fall back to another profile; enrollment saves a host only after a separate pinned key login. +Full offline protocol checks pass **944 tests / 98 suites**, with zero failures, errors or skips. +Offline app compilation, **151 affected desktop Vitest tests / eight files** with zero skips, and +full TypeScript checking pass. Actual JUnit inventories include the new setup tests. An obsolete +inline UI behavior source pin was removed; actual SSH authentication regressions remain. +All five Android jobs pass for the APK source: +[workflow 37233114167](https://github.com/CPlusPlus17/nodeterm/actions/runs/37233114167). + +The minified release build passes offline in **54.87 seconds**. Packaging uses the retained private +signer; all **56 independent artifact checks** pass, including official SDK 36/37 signatures, +nondebuggable version/manifest, R8/runtime keeps, payload/provenance and 16-KB ZIP/native alignment. +APK SHA-256: `9c5309c8cfb225daf59f7fac70be1760fb0abdaac36cb55005b958f2186c8ce3`. +Private APK: `.nodeterm/android-beta-16/nodeterm-android-0.1.0-beta.16.apk`. +Build proof: `.nodeterm/android-beta-build-16/`; behavioral/mutation proof: +`.nodeterm/android-ssh-setup-2026-10-04/`. Earlier failed or historical runs retain their separate +receipts; they are not counted as successful merged gates. The release receipt changes only docs. + +**Prepared, not installed.** Phone testing remains explicitly paused, beta 10/code 11 is last +confirmed installed, and the device ledger stays **10 Pass / 22 Partial / 32 Pending**. +When authorized, install beta 16 as a same-signer update and verify saved identities, profile +selection/switching/no fallback, password/fingerprint/cancellation UX on actual OpenSSH, and the +remaining managed-session, relay/cellular, notification and lifecycle matrix. MINA/POSIX fixtures +and in-memory app adapters do not verify physical Android or macOS behavior. Live Claude rule and +question application remains unverified after provider HTTP 401. A25 FCM and A93 fresh-desktop +relay recovery require the hosted-backend maintainers. For iOS, @eneskirca should adopt the saved +profile/enrollment UX and review the earlier host protocol changes. No PR opened; A68 remains +deferred until a PR is requested. Every push requires its own exact-head green Android workflow. + +## Native OpenSSH enrollment verification (2026-10-04) + +A120 now has an actual **OpenSSH 10.2p1** control run in addition to its MINA regressions. +The actual protocol sources at `bac1aa7fe90416b07dd0de47ac95049e817ce8b5` were compiled privately; +**28 distinct assertions / 40 passing assertion events** verify inspection against an independently +measured public fingerprint, password login followed by fresh pinned key login and exec, +retained unrelated keys, newline/mode handling, idempotence, consumed confirmation, rejected password +and host pin, commented key text, restricted keys, symlink/nonregular targets, and closed client sockets. +These are native fixture assertions, not additional Gradle/JUnit methods or physical checklist passes. + +The daemon ran as UID 1000 in a filesystem/user/PID sandbox exposing only read-only `/usr` and private +account files/home. Its password and SSH identities were newly generated for this test. It listened +only on loopback, with PAM, keyboard-interactive, forwarding, PTY and source penalties disabled. +This Fedora build warns that `UsePAM no` is unsupported; the asserted crypt/password and public-key +wire/exec flow nevertheless passes in that exact controlled configuration. This does not verify a +normal PAM/account backend, MFA, macOS, Android setup UX/Keystore, native cancellation/timeouts, +or profile/managed-session behavior. The earlier MINA tests retain their separate coverage. +Both disposable daemon runs are closed; their generated passwords/private keys/account hashes were +removed after the run. No host account, credential, SSH configuration, retained phone identity or +phone state was changed. Private proof: `.nodeterm/android-beta-build-16/openssh-native/`. + +The stale SSH class comment now describes A111's host-owned managed New support; missing existing +canvas sessions remain attach-only. It changes only KDoc, with the same source line count. +The prepared beta-16 APK remains source `2723845e`, code 17 and the same retained signature/checksum; +no new APK is built or installed for this verification/comment checkpoint. Fresh compilation in the +same AGP graph confirms every protocol class byte matches the immutable beta-16 build. This byte +comparison is a required gate before publishing the receipt. Required local gates and exact-head Android CI also remain mandatory. +Phone checks stay paused at **10 Pass / 22 Partial / 32 Pending**, with beta 10/code 11 last installed. +Actual Pixel setup/lifecycle, ordinary PAM accounts, macOS and live Claude checks remain open; +A25/A93 still need hosted-backend maintainers, iOS follow-up stays with @eneskirca, and A68 remains +deferred until a requested PR. No PR opened. + +## A121 + +**Canvas terminal blur can leave a stale link pointer (2026-10-05).** + +- Severity: **low**; effort: small; area: desktop renderer; kind: bug +- Status: **fixed in `01b4a2f5`; native Linux Desktop UI verified**. Separate Linux Server checks +pass at `dcdf664a`. The later GPU-enabled eight-case matrix passes (Canvas4 hardware / Modal4 DOM); +Those earlier runs did not verify GPU recovery; later individual pilots are recorded in the final +follow-up. macOS remains unverified. + +**Observed:** the isolated full Linux Electron Desktop UI at `c0accdfcb0642dc2d643e1794640065f61906f34` rendered a wrapped URL through the real plain NodePTY and bundled xterm DOM renderer. Tail hover, complete URL activation by Ctrl-click, and native non-drag movement outside the terminal were delivered. In the SGR mouse case, moving to the canvas left xterm's active link and pointer state set (`A92_HOVER_CLEAR_FAILED`). No pointer capture was active. The normal mouse case completed the same leave successfully. + +**Cause at `c0accdfc`:** `src/renderer/nodes/TerminalNode.tsx` called `term.blur()` synchronously from `onBodyLeave`. The bundled xterm DOM renderer handles blur by immediately rendering all rows with `replaceChildren`. The native trace records screen `pointerleave` and `mouseout`, then destination `mouseover` whose former span is detached, with no actual screen `mouseleave`. Linkifier clears its hover on that missing `mouseleave`, so its `mouseOut` stays false and pointer/link state survives. The visible row underline can disappear during blur even though Linkifier's hovered decoration state and pointer remain; this finding does not claim every visible underline persisted. + +**Source repair:** `terminal/deferred-blur.ts` defers only the captured terminal's blur with `setTimeout(0)`. Re-entry, intentional focus and lifecycle cleanup cancel it; generation and exact current-terminal identity reject stale callbacks, including same-object park/adopt. The controller remains reusable after cleanup. Status/presence release stays synchronous. Nine behavioral methods pass, all 63 tests in four affected terminal files and the full TypeScript check pass, and five isolated production mutations fail on assertions (immediate blur, microtask, ignored cancel, missing generation, missing owner). The control/restored copies pass. Private proof: `.nodeterm/android-beta-build-16/desktop-links-and-advertisement-receipt/a121-unit/`. Fresh full control and restored builds at `01b4a2f5` each pass all eight actual native Linux Desktop cases; the synchronous-blur mutant fails on named hover-clear after trusted delivery. See the final receipt below. + +**Scope:** Desktop and Server share the Canvas `TerminalNode` handler. The original diagnosis is Linux Desktop; separate genuine Linux Server execution at `dcdf664a` now verifies the shared handler through its authenticated HTTP/WebSocket bridge. Modal terminal leave does not use this handler. Android's terminal is unaffected, and this repair changes no external protocol or iOS contract. The Android APK source, installed phone version and 64-item physical ledger stay unchanged. + +**Separate A92 evidence:** the old upward-bound link mutant already fails with the named `A92_TAIL_HOVER_MISSING` in a real native tail hover. That established sensitivity to A92's lookup cap defect while the initial control was incomplete. The final current-source eight-case controls and both full-app mutants now pass; preserve the original preliminary receipt separately. + +Private evidence: +- Current failed control: `/tmp/nodeterm-a92-current-ys5prgif/runtime/control-7yoizvhv/proof/{receipt.json,runtime-result.json,canvas-soft-sgr-hover-clear-failed.png}`. +- A92 old-bound mutant: `/tmp/nodeterm-a92-mutant-7dyrrp0u/runtime/old-upward-bound-mgnkw593/proof/{receipt.json,runtime-result.json}`. + +## A122 + +**Focused Desktop xterm consumes the advertised native Quit chord (2026-10-05).** + +- Severity: **medium**; effort: small; area: Desktop terminal keyboard; kind: bug. +- Status: **source-fixed in `9613cec519c1469aff10371bf0acf97adb90ed1a`; four Linux native cases pass**. +- Locations: `src/renderer/terminal/terminal-config.ts`, `src/renderer/nodes/TerminalNode.tsx` + and `src/renderer/components/kanban/ModalTerminal.tsx`. + +Earlier full-product Linux Desktop attempts delivered native Ctrl+Q while the terminal was +focused but reached no native confirmation. The actual File-menu and outside-terminal Ctrl+Q +controls later confirm natural Quit on the historical `972f7994` / built `01b4a2f5` checkpoint. +The original V5 key observation is document-capture/main before-input evidence, not the final +renderer default-prevented state. Current source identifies the ordinary xterm Ctrl+Q/XON path +as the missing native-role exception; `terminalKeyAction` now returns `native` for only the exact +printed platform Quit chord on Desktop keydown/keyup before policy or registry handling. +Canvas and Modal pass their actual Desktop/browser identity. Neither handler prevents default +for this branch. Server and other terminal chords keep their existing bytes/behavior. + +133 focused policy tests and all 218 tests in three affected files pass with full TypeScript +checking. Six isolated compiled mutations are assertion-caught with passing control/restored +runs. A fresh full Desktop build is source-bound to `9613cec5`; all four native Linux +Canvas/Modal × app-first/terminal-first cases pass. Actual owned textarea/main focus at dispatch, +parented native Quit confirmation, Escape Cancel retaining the exact PTY, and later natural +File-menu Quit with own PTY/hook retirement are verified. See the final checkpoint section. + +The fix changes only Desktop renderer shortcut handling, not Android, SSH/relay contracts or iOS +fields. macOS/Windows native delivery remains unverified. No APK, installed-phone or physical +ledger change follows from this source repair. + +## A123 + +**A restored addon context stays live after disposal and budget retirement (2026-10-05).** + +- Severity: **low**; effort: small; area: Desktop Canvas GPU lifecycle; kind: bug. +- Status: **fixed in `db83fb7dfc59c336083769edf0943bf82b367c58`; Linux native control/mutant/restored verified**. +- Locations: `src/renderer/nodes/TerminalNode.tsx`, `src/renderer/terminal/webgl-budget.ts`. + +At source `6bfa5412`, a genuine quick original `webglcontextrestored` event restores the context. +The owning Canvas listener disposes the addon and removes its budget grant, but addon-webgl 0.18 +only detaches its canvas/deletes resources. The native observer sees the original context healthy +at readable DOM fallback, fresh hardware regrant and final observation. The restore hook clears +its local `webgl`, but `webglAddonRef.current` still references the disposed addon until replacement; +the passive fixture also retains the captured context for observation. This establishes missing explicit retirement, not a reproduced Chromium cap-pressure +failure or a permanent leak. Page-wide and repeated pressure consequences remain an inference. + +The repair explicitly calls `loseWebglContexts` on the captured original addon canvas after +disposal and before notifying the coordinator. It preserves stale-addon ownership, already-lost +cleanup and the delayed budget-gated regrant policy. Two new tests cover detached captured +canvases and continuation after an earlier canvas throws. All 44 budget tests, 301 affected tests +in seven files and full TypeScript checking pass. Full helper control/restored runs catch two +exact assertion mutants; these helper checks do not prove TerminalNode wiring. + +Three fresh full builds at the signed repair source supply separate actual native wiring proof. +Both fixed controls observe a trusted original restore before production's handler, production's +second native loss, readable stock DOM and distinct fresh NVIDIA WebGL2. The sole-call-removal +compiled mutant fails exactly `GPU_RETIRED_CONTEXT_NOT_LOST` after three trusted native input +acknowledgements and two complete URL controls pass; old context remains live. Startup/focus/ +fixture failures supply no mutation credit. Every run preserves the same terminal, buffer, grid +and real PTY birth, with no fixture second loss, GC or forced redraw. Explicit fixture app exit +supplies no ordinary-Quit proof. See the final follow-up and its private source-bound archive. + +The shared Desktop/Server Canvas hook receives this repair when using the per-terminal WebGL +addon. Source-bound native retirement checks pass for Linux Desktop at `db83fb7d` and the later +genuine Node Server/browser Canvas soft/none pilot at `fa259f6e`. A separate derived Desktop cell +correlation uses three existing `db83fb7d` captures; it adds no Server runtime or synchronized/ +full-frame compositor proof. Android, external SSH/relay contracts and iOS fields are unchanged; +no APK or phone ledger change follows. Other platforms, synchronized/full-frame compositing, +physical-display output, shared glyph eviction and repeated/page-wide pressure remain unverified. + +## A124 + +**Managed SSH New rejects an available absolute shell as unavailable (2026-10-05).** + +- Severity: **medium**; effort: small; area: shared Desktop/Server executable resolution; kind: bug. +- Status: **fixed in `651f46da239cb0877c3bfccc6a478e1b555a63cc`; one physical Pixel 7a managed-shell case verified on `40731381`**. +- Locations: `src/core/exec-path.ts` (`findInPathString`), `src/core/managed-terminal-plan.ts` + (`createManagedTerminalPlanner`), `src/core/pty-manager.ts` (`resolveLocalSessionShell`). + +Fresh beta 16/code 17 on the separate Pixel 7a reaches a genuine owned Desktop host at +`15dd1341c55302f9936b83a19d82c165733320c9` through manual explicit-profile SSH. Existing-terminal +input/cwd/history pass, but **New session → Start with default Claude selected** reports “The +configured shell is unavailable.” The original XML shows that tapping a nonclickable option label +did not select Terminal. This is still shared shell validation before any agent CLI resolution; +the repaired wrong-label retest correctly refuses the missing Claude CLI and is not a new finding. +The host has blank `defaultShell`, inherited `SHELL=/bin/bash` and an available +executable `/bin/bash`; the planner calls the real login-PATH resolver. That resolver joins the +absolute program beneath each PATH entry, checking unrelated paths such as `/usr/bin/bin/bash`. +The observed workspace retains only its original Desktop terminal. + +The repair validates the exact absolute path with the platform's executable-access check and a +regular-file check, returning null on failure without PATH fallback. Bare-name/PATHEXT behavior +is unchanged. Real-filesystem regressions cover an absolute executable outside PATH, absent and +non-executable files, directories and a joined PATH shadow. Planner integration uses the actual +shell and login-PATH resolvers with a disposable inherited absolute shell and blank defaultShell. +All **38 affected Vitest tests in two files** pass; five platform cases skip on Linux. Full +TypeScript checking passes. Network/PID-isolated control and restored runs pass; removing the +absolute branch fails three regressions, removing the regular-file check fails directory rejection, +and removing executable access fails non-executable rejection. Three earlier fixture setup +failures stopped before Vitest and earn no mutation credit. + +The actual shell radio is independently selected in `repaired-shell-selected.xml`. On frozen +Desktop `40731381`, the phone creates/registers `/bin/bash` node `term-muvrrd9n-623cda88cddd8ded`, +pane PID 1101, and input executes exactly once in the project cwd. The original terminal/PID 368 +remains unchanged. Viewer close/reopen and Android process restart (9258 → 14735) with saved SSH +reconnect return to the same managed pane and visible old marker, without resetting app identity. +After 150 further output lines, Find reports two matches in 155 retained lines; native tmux `-S 0` +excludes the old marker from the visible screen. History selected-line clipboard paste matches. +Local Copy-sheet search correctly has no offscreen-marker match; URL offer and URL clipboard paste +pass. No external browser-open result is claimed. + +Both owned host profiles are removed through the app and the phone returns to Computers/Add; +beta 16 and global identity remain. V5 then stops at clean `40731381` with runner success/exit zero, +graceful requested stop, app/server exit, closed listeners and generated private-auth cleanup. +The old host's negative source-binding receipt explicitly preserves the earlier working-tree edits. +The exact `40731381` offline protocol gate passes 949 methods / 98 suites with zero failures, +errors or skips; the full frozen Desktop build passes in 29.3 seconds. First-push +[Android run `37375870878`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37375870878) +passes all five jobs against `40731381c646ccd3425af597b116b5371daf0a63`. + +Physical proof is `.nodeterm/android-pixel7a-device-y0ia4hm_/physical-repair-result.json` and its +named evidence; source/regression proof is +`~/.cache/nodeterm-android-work/a124-executable-mutation-lhf2f_p6/proof/`. This single manual high-port +SSH shell case does not verify live agents/provider answers, managed End, power loss, QR/relay/ +cellular routes or the full background/outage/permission/64-item matrix. The original Pixel 10 Pro +beta-10 ledger remains paused at **10 Pass / 22 Partial / 32 Pending**. +No phone payload, host verb or SSH-visible file contract changes. The shared host repair also +benefits iOS managed creation; @eneskirca should note the host update, with no new client field. + +## A125 + +**Open SSH card does not follow automatic reconnection; inactive cards use the active owner (2026-10-06).** + +- Severity: **medium**; effort: small; area: Desktop card/SSH renderer lifecycle; kind: bug. +- Status: **source-fixed in `415dae9b`; eight bounded Linux Desktop reconnect cases pass at `5b7286b3` with A126; one two-host inactive-global case passes at `59e4c93e`; wider scope remains open**. +- Baseline: `11e22453235dbd8bf474dd8034a0eaf5778c3992`. +- Locations: `ModalTerminal.tsx` exit listener and nodeId-only attachment effect; `CardModal.tsx` + stable card/view key; `GlobalKanbanView.tsx` owning swimlane; `TerminalNode.tsx` + `sshConnectionScope`/`resolveSshRemote`; `Canvas.tsx` active/inactive reconnect respawn. + +The baseline Modal exit callback only writes the ended banner, and its attachment effect depends +only on nodeId. Canvas queues connection exit 255 and bumps its own respawnNonce after successful +reconnection, while an inactive owner's branch only retires parked Canvas viewers. That transient +nonce is intentionally not serialized into the global board's stored node projection. The global +lane keeps modalNodeId and reads its latest card through byId; a selected-object cache is not the +cause. The card also receives no explicit owning project, so the SSH helper assumes the active tab. + +The `415dae9b` repair publishes renderer-only exact scope/node reattachment notifications at the existing +coordinator's successful pending-node flush. Each Modal generation subscribes before async attach, +marks itself lost before reporting remote 255/unavailable and replaces its own viewer only for a +matching notification. Cleanup fences late async/refused results and detaches the exact viewer. +Both boards pass the owner; scope resolution retains active ownership as the default for existing +Canvas callers. The card remains mounted and creates with the original persistKey/requireRemote, +without any initial/pending command or agent resume delivery. No persisted/wire contract changes. + +Nine actual mounted Modal/Global-board cases use the real LocalTransport and SshReconnector; +four coordinator subscription cases cover failure/success, exact routing, co-viewers, racing +status/promise completion and retired unsubscribe. All **100 affected tests in 11 files** pass +with zero failures/skips, and full TypeScript passes using ordinary composite/incremental caches. +Six isolated variants remove the 255 report, flush notification, scope/node filtering, global owner, +late-dead guard or exact-viewer cleanup; each fails intended assertions (respectively 2/7/2/1/2/1 +failures), with **28-test** passing controls/restoration and bound unchanged candidate/main sources. + +**Admitted native baseline.** The genuine Linux Desktop Modal soft/none case on source/build +`11e22453` admits selected Modal, hidden Canvas and remote pane at `80x24` before one producer +paint. Initial trusted native input and complete URL controls pass. A single birth-pinned product +ControlMaster SIGKILL produces exit 255; the product reconnects its master and Canvas (`pty-2`), +but the same open Modal remains on `pty-1` through a six-second coherent recovered-state window. +The second trusted native `g` produces one exact IPC write to `pty-1`, misses its two-second ACK +deadline, and an additional 800 ms observation still shows producer count one. This is classified +`MODAL_RECONNECT_INPUT_UNACKNOWLEDGED`, with fixture exit 3 in 26.43 seconds. Remote pane/server/ +producer identity and complete retained history remain unchanged before, during and after outage. +Exact owned session/observer/master/daemon cleanup and generated-auth removal pass. Explicit +fixture exit supplies no ordinary-Quit proof. Earlier geometry-calibration refusals stay separate. + +Native proof: `~/.cache/nodeterm-android-work/nodeterm-linux-ssh-reconnect-case-_r9f0qvs/proof/`. +`receipt.json` SHA `18db5b3ebfeb32db1881a1183a259b68944ae9ff8fe6c95a7bb6b30c90faa1e5` records the +verified product negative; `runtime-result.json` SHA +`137bca085ceddb38db3f52fe0ce8a17fc74d1f1d785c1bff169e317457eee797` binds the actual input evidence. +Separate Canvas controls at the old `11e22453` source are historical baseline controls, not +repaired-Modal acceptance. The first fresh `415dae9b` control creates replacement Modal and +Canvas viewers but exposes [A126](#a126) duplicate automatic replies before ACK 2 is attempted. +Its original unclassified exits and later diagnosis remain separate. With A126 fixed in `5b7286b3`, +the eight bounded Linux Desktop reconnect cases pass; [A126 below](#a126) binds that separate +acceptance. The later two-host case below at `59e4c93e` also verifies one quiet park/adopt cycle +and inactive global-card recovery with active A unchanged. Wider parking, Server/Android/phone +outages, physical custom bindings/FPS, provider-authenticated answers and macOS/Windows remain +unverified. The original Pixel 10 Pro beta-10 ledger stays paused +at **10 Pass / 22 Partial / 32 Pending**. This Desktop renderer repair changes no Android APK, +host-service verb, blob/pairing/mirror/SSH-visible file or iOS client adoption requirement. + +Private candidate proof: `~/.cache/nodeterm-android-work/modal-reconnect-candidate-g37ek98k/` +(`candidate-result.json`, SHA `7a54a18e823f26067ac46f9022bc58670062a61d52084078deb0bdd9bb5c824d`; +full source patch SHA `f17b1b919980c15a79ba286b1216898632eddf7cbe91ce3acb387f873e63a3bd`). +Actual mutation proof: `~/.cache/nodeterm-android-work/modal-reconnect-mutations-qcaln9wi/` +(`mutation-result.json`, SHA `ed1e89f8cf26c6a59da993087851ab39dc22ae1e0d240594274cb337cdb7611d`). + +## A126 + +**Co-view xterms duplicate automatic terminal replies into the retained process (2026-10-06).** + +- Severity: **medium**; effort: small; area: Desktop renderer terminal input; kind: bug. +- Status: **source-fixed in `5b7286b3`; regressions/TypeScript/mutations, forced950/98/app gates and eight bounded Linux Desktop reconnect cases pass; one quiet native park/adopt + inactive-global case passes at `59e4c93e`; wider scope remains open**. +- Observed source/build: `415dae9b66b74c5165fab312e6c4cda3ed78a957` (A125 repair). +- Locations: `TerminalNode.tsx` and `ModalTerminal.tsx` unconditional xterm `onData` forwarding; + `src/renderer/terminal/xterm-input.ts`; installed xterm 5.5 `CoreService.triggerDataEvent`. + +**Retained failure and subsequent diagnosis.** One owned product ControlMaster SIGKILL triggers +normal automatic recovery in the fresh Linux Desktop Modal soft/none control. Replacement Modal +and Canvas create requests use the new session. Both views then send automatic DA1/DA2 responses +(7 and 11 bytes each). The retained strict producer records 18 unexpected bytes with SHA +`5df79d99ea5f3f007797bd1da48825081c9b5db6800cd1c8935c92218c3ab7ed`, exactly matching +`ESC[?1;2c` followed by `ESC[>0;276;0c`. Its initial trusted native `g` ACK is clean; final count +remains one, and ACK 2 is **not attempted**. Replaying output to two xterms must not inject a second +set of automatic replies into the live inner process. Do not whitelist these bytes in the producer. + +The original wrapper/runtime exit codes are **1/2**; the retained receipt remains +`admission-or-verification-unclassified`. Root and peer review subsequently diagnose A126 from +that unexpected-input digest and actual xterm source. This later interpretation does not rewrite +the receipt or turn the failed run into a positive. Completed before-outage/gap observations retain +their own same-grid history/pane/producer evidence; no complete recovered history, native input, +wrapped-link matrix or broader outage/platform result is established by this failed control. + +Proof: `~/.cache/nodeterm-android-work/nodeterm-linux-ssh-reconnect-case-_llfwbzw/proof/`. +`receipt.json` SHA `91d894114b86b167c072a27b7e53ddadbd711cf3dcbed6f18ebdd184d3b48cdf`; +`runtime-result.json` SHA `b3eab62f56c7a3d755498c84def73b320144be22937531c91568a4ae66f05930`. +The earlier `11e22453` A125 stale-card product negative, historical Canvas controls and geometry +calibration failures retain their original source bindings and classifications. + +**Source repair and bounded verification.** The independently reviewed `xterm-input.ts` repair +uses xterm 5.5's actual input origin: public `onData` erases that flag. One live automatic-response +owner is scoped to the exact `api.pty` object and actual session id within one renderer. Exit, +closed/recycled subscription and final disposal release it, promoting another live view. Genuine +keyboard/paste/SGR mouse input and actual DOM focus/blur from every view remain intact. For +automatic DEC1004 focus-state replies, prefer the connected textarea that is its owner document's +actual active element; a stale focus class is insufficient. Canvas park retains the xterm, +subscription and lease, and adoption does not rebind or rewrap it. Actual Canvas mounting passes the +separate native matrix below; the later two-host case adds one quiet native park/adopt cycle with +carried-writer SID provenance, without a post-adopt Canvas input/current SID-ref claim. An unsupported private xterm shape warns +explicitly and preserves public input through a fallback where duplicate replies remain possible. +The legacy `onBinary` path is unchanged; no new integration proof is claimed. + +Sixteen new cases comprise **nine installed xterm 5.5 dependency cases**, **one explicit unsupported- +shape shim case** and **six mounted Modal cases** with fake rendering and real helper, +LocalTransport and SshReconnector. They exercise automatic parser replies, reply-shaped genuine +input/paste, actual SGR mouse-origin dispatch, real private browser focus/blur, focus-query election, +live promotion/retirement, exact API/session scopes, nested origin restoration and synchronous +first output. All **294 unique affected tests in 17 files** pass with zero failures/skips; full +TypeScript passes with ordinary composite/incremental caches. These public `Terminal.input` checks +remain distinct from the separately accepted native textarea/mounted-Canvas observations below. + +Nine actual isolated variants remove ownership, user origin, API/session scope, promotion, +focused-query election, genuine-focus origin, pre-output binding or Modal generation retirement. +Each fails intended AssertionErrors (respectively **9/3/1/2/8/2/1/1/3** failures). Both **25-test** +control/restored runs pass, with candidate/main bindings and restored source hashes retained. +Private final candidate proof: `~/.cache/nodeterm-android-work/a126-response-owner-candidate-g0ish79q/`. +`candidate-final-result.json` SHA +`21009c892e4c2dc0ec676d6c09ad67b8a8a34ef9cd06ccf8b43d45a3bb551f12`; +source patch SHA `495104f45339832a0b04f98e09177ce57bd63d5c698f5c11563c056c11f4756d`. +Actual mutation proof: `~/.cache/nodeterm-android-work/a126-response-owner-mutations-hwj1f9iq/`, +`mutation-result.json` SHA `73f4a110bbf6256c81796fb062d7aec485a8d04ba3138fcdacfe206765cbfc50`. +The earlier receipt's wording counted all ten helper cases as installed-xterm cases; the final +receipt preserves that history and explicitly qualifies the one fallback shim separately. + +**Accepted native reconnect matrix.** Root independently accepts all eight cases on exact +source/built revision `5b7286b302440c210bb204fbe2d7821f7b37b9f0`, tree +`24da00670b41251ef61c2a914a77b4b2a8afbe03`. +Eight actual Linux Desktop **Canvas/Modal × soft/hard wraps × inner none/SGR mouse** cases +pass at `5b7286b3`. Each loses one birth-pinned product ControlMaster, creates a new master/ +selected local viewer and retains its remote pane/server/producer/cwd and complete same-grid history before, +during, after and at final observation. Both trusted actual textarea `g` inputs are acknowledged +on the old/new PTY: **16 ACKs**, **zero unexpected bytes**, one producer paint per case and +**16 complete URL activations**. Native hover/leave and exact owned cleanup pass. Modal cases +use `80x24`; Canvas cases use `49x48`, with each history comparison bound to its own grid/case. +The selected open Modal stays open and replaces its viewer. The strict producer/classifier is +unchanged; no automatic-reply bytes are whitelisted. + +| Surface | Wrap | Inner mouse | Grid | History lines / bytes | Receipt case | +| --- | --- | --- | --- | --- | --- | +| Modal | soft | none | 80x24 | 74 / 6550 | `qwis3lax` | +| Canvas | soft | none | 49x48 | 74 / 6941 | `9ypju82g` | +| Canvas | soft | SGR | 49x48 | 74 / 6941 | `3jmljmrc` | +| Canvas | hard | none | 49x48 | 112 / 6979 | `gguaxz39` | +| Canvas | hard | SGR | 49x48 | 112 / 6979 | `eq912pi1` | +| Modal | soft | SGR | 80x24 | 74 / 6550 | `7fq_jkva` | +| Modal | hard | none | 80x24 | 92 / 6568 | `qkf4lynp` | +| Modal | hard | SGR | 80x24 | 92 / 6568 | `c4t8_1qo` | + +Private native aggregate: `~/.cache/nodeterm-android-work/a125-a126-native-matrix-ww8e8nwo/root-acceptance.json`, +SHA `d8a5366b83d4c4b3ada9a1112521f762905ce3b835be4a32983ce9150f4f391a`. +It binds all eight receipt/runtime hashes, geometry/history/timing and exact cleanup. +Each case is `nodeterm-linux-ssh-reconnect-case-/proof/receipt.json`; root independently +checks raw runtime/service/log/ready/tool evidence. The historical `11e22453` stale-input and +`415dae9b` unclassified DA controls remain unchanged. + +**294 unique affected tests / 17 files** and full incremental TypeScript pass. **15 isolated +mutants (six A125 + nine A126)** are caught by assertions; each fix's control/restored runs pass. +Forced offline checks at `5b7286b3` execute **950 tests / 98 suites**, zero failures/errors/skips, +and both protocol/app Kotlin compile tasks. These are disposable QA hosts; no new APK or regular +Desktop installation is included. Publication requires fresh offline gates and all five Android +CI jobs for the exact published HEAD; its private bundle and final report carry that run's result. +Forced protocol executes in 128.019 seconds; app/protocol Kotlin compilation in 19.041 seconds. +All 2,465 tracked source bindings and tools stay unchanged. Private gate receipt: +`~/.cache/nodeterm-android-work/5b7286-offline-gates-_68rpcak/gate-result.json`, SHA +`4c35734a2c9654d4d76f061defa147281875f25aa33e046ba8b44da5593e9e0e`. +A125/A126's 15 actual mutants retain separate 28-/25-test passing control/restored runs. + +**Bounded two-host global-card and park/adopt follow-up.** One actual Linux Desktop soft/none +case passes at exact source/build `59e4c93e5b1256e9882205700d4907a5d43a8e20`, tree +`0f4753c65b858003a0d1560c3cf278cbffb48fd0`, in 27.39 seconds. Two distinct private OpenSSH hosts +have independent owner projects and remote PID namespaces, with source-reviewed private home/tmp +mounts; a separate raw mount-namespace probe is not retained. B's global Modal stays open through +ordinary native palette B → A → B → A switches; one quiet pre-outage park/adopt cycle retains +its installed xterm/core/input/focus wrappers, ordered subscriptions/API, full renderer buffer, +remote history and existing native client/master. The SID witness comes from actual primary +create/native Canvas `g1` and the carried identical writer with no create/detach/client replacement; +Canvas's lifecycle-closure SID is not an observed current/adopted React ref. No post-adopt Canvas +input or streaming/expiry/eviction result is claimed. + +With actual A active/mounted and unchanged, B alone loses its birth-pinned product ControlMaster. +Its continuously open inactive global card recovers on B's new master and fresh warm selected +viewer/session; no inactive-B Canvas creation or launch replay occurs. Native Canvas `g1`, inactive +Modal `g2` and recovered Modal `g3` each receive one exact ACK. Both complete wrapped URL controls, +zero unexpected input, one paint per role and full `80x24` retained B pane/server/producer/cwd/history +before/during/after/final pass. A master/client/pane/producer/grid/history/input/routing stay unchanged +within the acceptance window. Exact per-role observer/session/master/daemon cleanup and generated- +auth removal pass. This adds no product repair, test or mutation count, APK, regular Desktop update +or ordinary-Quit proof. See [the bounded two-host follow-up](android.md#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06) for detailed scope and build pins. + +Private proof: `~/.cache/nodeterm-android-work/nodeterm-linux-ssh-global-owner-6lens2kd/proof/`. +`receipt.json` SHA `e923f8fca25e05aa9869ab3bd22e4b2444147e41621c2bf0a8ca38465ece78c3`; +`runtime-result.json` SHA `313413ea48f371b6fbc62e2d03c14af7f0b0fcd0c573110c9d18137f0da13adb`. +Root acceptance: `~/.cache/nodeterm-android-work/a125-a126-global-owner-root-mq6v7w7z/root-acceptance.json`, +SHA `7cb20c71a52dfc3bf968073170867b6e7ff199957e23a664a5ffebb5e3ab453d`. +Independent review: `~/.cache/nodeterm-android-work/ssh-global-owner-independent-review-kn_5dsw4/independent-review.json`, +SHA `7fc9ace6926a5004ee5a8c817d664fde59f8cf6d83d26a9e1ca5fa3a03cd869a`. +The earlier `nodeterm-linux-ssh-global-owner-3fmkgrkj/proof/` fixture refusal stays historical: +exit 2 before park/outage required a nonexistent Canvas React SID ref. It receives no product- +negative or repair credit; the corrected driver preserves that diagnostic and every other oracle. + +Actual Canvas mounting and selected-view native keyboard recovery pass in the eight-case matrix; +one additional two-host soft/none case verifies a quiet park/adopt cycle and inactive global-card +recovery with the active host unchanged. See [the bounded two-host follow-up](android.md#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06). Remaining Android release acceptance needs a +reachable intended phone for lifecycle/outage, answer/notification/permission and live-provider +checks; A25/A93 need hosted-backend maintainers. Further host coverage includes streaming/expiry/ +eviction while parked, repeated or broader outage/backoff, Server/cross-window response ownership, +GPU pressure/full-frame display output and macOS/Windows. The synthetic key-only hosts do not +establish real-user PAM, power-loss, agent-launch or ordinary Quit behavior. Unsupported private +xterm shapes warn and retain input but may duplicate replies; legacy onBinary is unchanged. +No Android wire/file or iOS client change is required. The original Pixel 10 Pro ledger remains +paused at **10 Pass / 22 Partial / 32 Pending**. + +## Native Desktop wrapped-link and leave verification (A92/A121, 2026-10-05) + +Signed source `01b4a2f5` now passes the full actual Linux Electron Desktop UI check. Four fresh +full builds consume all **2,462 exact raw Git blobs**, with only the one approved source literal +changed in each mutant. Control and restored runs each pass all eight combinations of Canvas +and maximized Board modal, hard/soft wraps, and normal/SGR mouse reporting. A real plain native +NodePTY paints the long run; native trusted XTest tail hover shows the entire URL underline and +pointer, plain click opens nothing, Ctrl-click opens exactly the complete URL once, and a native +non-drag leave clears the pointer and underline. No direct provider or activation call is used. + +The old upward cap fails with `A92_TAIL_HOVER_MISSING` after calibrated native tail hover. +Restoring synchronous Canvas blur fails with `A92_HOVER_CLEAR_FAILED` after trusted hover, full +Ctrl-click activation and native leave, retaining pointer/link state. The final blur mutant fails +in Canvas soft/SGR; an earlier run also reproduced it in hard/normal mode. Nine controller tests +and five assertion mutants cover cancellation, replacement, same-object park/adopt and cleanup +reuse; all 63 affected terminal Vitest methods and full TypeScript pass. + +Private durable proof: `.nodeterm/android-beta-build-16/desktop-links-and-advertisement-receipt/` +contains `a92/results.json` with 72 source/build/runtime evidence files, `a121-unit/results.json` +and `a121-affected/result.json`. Network/PID/home isolation and unchanged source/tools/held output +checks pass. Earlier archive-line-ending refusal, phase-specific classification rejection and +quit timeout are preliminary recipe results; they were not counted as product failures or final +controls. The final disposable fixture uses explicit exit after recording the checks; ordinary +app quit is outside this proof. Synthetic SGR reporting does not verify a real TUI/tmux/SSH path, +Server Edition, GPU, macOS or Android. No APK, host/client contract, iOS adoption or physical +checklist promotion follows. Phone testing remains paused at **10 Pass / 22 Partial / 32 Pending**. + +## Native Server wrapped-link and leave verification (A92/A121, 2026-10-05) + +Signed source `dcdf664a4ffa0cf752350c29fc21843f74c93ae1` passes separate genuine Linux Server +control and restored runs, each with all eight Canvas/Board-modal, hard/soft-wrap and normal/SGR +cases. Four fresh full builds use 2,462 raw Git blobs; each separate `server:build` adds only +`server/main.cjs` to the unchanged 136 artifacts. Actual Node 26 runs the shipped Server entry. +Chromium loads its real login form and HTTP renderer with no Desktop preload, Node integration +or injected API. Passive observation confirms authenticated `/ws` handshakes, actual PTY create +requests/replies and binary output. Native trusted hover, full underline, plain/Ctrl click and +non-drag leave assertions pass. Both the old-cap and synchronous-blur mutants fail their named +assertions after calibrated native input; restored source passes again. + +Private proof: `.nodeterm/android-beta-build-16/server-links-receipt/a92/results.json` and its +105-file manifest bind source, builds, recipes and runtime. Three earlier browser-bootstrap +failures remain separate infrastructure evidence, with no terminal assertions or product finding. +Loading the actual login document before enabling debugger network observation resolves the +runner stall. Final owned Node children close on SIGTERM; disposable Chromium exits explicitly +after the report. This verifies Linux Server DOM rendering with a synthetic reporting-mode TUI; +real tmux/SSH, GPU, macOS, ordinary quit and Android UI remain outside its scope. + +No runtime or external contract changes follow from this verification. Beta 16/code 17 remains +prepared from `2723845e`, uninstalled; beta 10/code 11 is last installed. Phone testing stays +paused at **10 Pass / 22 Partial / 32 Pending**. Next: resume the Pixel update/checklist when +authorized; live A105/A106 needs a usable provider account after HTTP 401; A25/A93 need hosted +backend maintainers. The Android contribution is in this repository. No PR is opened; A68 waits +for requested PR preparation. + +## Native setup timing, installed PAM and remaining desktop checks (2026-10-05) + +A120 now passes four native OpenSSH cancellation/deadline cases at clean source +`7cf2bb339d73c98e7876a7ce1edec5f6f3585141`, with **76 case-scoped assertions**. +All 76 protocol source files were compiled privately with Kotlin 2.2/JDK 21 and SSHJ 0.39. +The pre-install gate admits real password authentication and the exact production exec request, +then holds it without running the installer; cancellation/deadline leaves authorized_keys unchanged. +The post-install gate retains the actual installed key, accepts a third connection and reads a +real upstream SSH banner before withholding it. That case does not complete a third key exchange +or key authentication. External cancellation remains CancellationException; the operation's own +12-second post-dispatch deadline reports HostUnansweredException. Neither returns a PairedHost. +Owned client sockets, gates and daemon descendants close; caller password buffers clear, consumed +confirmations cannot replay, unrelated key bytes/modes survive, and generated secrets are removed. +This is protocol/native proof, not physical HostStore, Keystore or Android background/setup UX proof. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/openssh-timing/`. +The earlier 28 distinct native assertions / 40 events and 17 JVM methods / seven product mutants +retain their separate inventories; these new fixture checks are not new JUnit methods or mutants. + +A separate **17-check native installed-PAM control** passes against OpenSSH 10.2p1 and this host's +unchanged Fedora PAM stack. It uses a namespace-only synthetic NSS/shadow account and a UID/GID +1000 client with every process capability cleared. Actual logs record one accepted password, +two accepted key authentications, three successful PAM account checks, and three matched session +opens/closes, with no PAM session/credential errors. Public fingerprint confirmation, independent +retained-key exec, wrong-password/pin refusals, key-file preservation, no replay and socket cleanup +pass. All 31 public installed-stack bindings remain unchanged, SELinux remains enforcing, and +generated credentials plus owned daemon descendants are removed. This reuses 513 class files from +the earlier timing compilation after verifying all 76 protocol source bytes; only the 11 standalone +driver class files are freshly compiled. It does not verify real-user policy, MFA, ordinary systemd +user-session registration, macOS, or Android UI/storage. Earlier setpriv and private-log traversal +failures are retained as failed fixture attempts, without product fixes or mutation credit. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/runtime-checkpoint-supplement/`. + +The earlier RTX 4090 WebGL2 probe verified the environment only; the successful PAM namespace +admission performed no authentication. Their historical scope is unchanged. Later product GPU +and installed-PAM results supply the separate evidence recorded here. + +Earlier focused-terminal Ctrl+Q attempts remain nonpositive. V5's trusted renderer capture and +main before-input observations do not establish `defaultPrevented` after xterm's target handler. +Minimal Electron Quit controls verified the environment, not product Quit; failed fixture and +classifier attempts remain separate from the successful runs below. + +**Historical Linux Desktop runtime checkpoint:** current source `972f7994` differs from built +Desktop `01b4a2f5` only in six documents. Native File-menu Quit/Cancel and a separate outside-terminal +Ctrl+Q/Cancel both pass, followed by native File-menu confirmation and natural zero exit. Cancel +preserves the exact own PTY/workspace/window; Quit retires the own PTY/hook and saves state. +The GPU-enabled eight-case link matrix passes with eight complete URL opens: four Canvas cases +use the actual NVIDIA RTX 4090 xterm WebGL2 context, real glyph pixels and production 2D link +underline before/hover/leave; four Modal cases intentionally use DOM. The single GPU pilot stays +separate. A real shipped-tmux Canvas soft-wrap/inner-no-reporting pilot also passes one native +hover/plain-click/Ctrl-click/leave case while preserving the exact pane/client and shipped mouse-on +options. GPU and tmux fixtures use explicit `app.exit(0)`, supplying no ordinary-Quit proof. +These do not verify GPU context recovery/compositor pixels or full tmux/SSH matrices. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-checkpoint-20261005/`. + +**A122 is fixed in `9613cec519c1469aff10371bf0acf97adb90ed1a`.** Desktop Canvas/Modal now keeps the +exact native Quit chord ahead of xterm on keydown/keyup under either shortcut policy, without +preventing the native menu. Server retains Ctrl+Q/XON. 133 focused policy tests, 218 affected tests +in three files, full TypeScript and six assertion-caught isolated mutations pass. The fresh full +Desktop build is bound to that exact source (snapshot `57e09375369a1a68f0c28b7975616561aad6715a4dc05a6fc5eeb00d0dc0706c`; +build receipt `86eb82912ab7aa8a853a7236bacebc40ffbd9eff6845f3b79caaac83a029f5ff`). +All four native Linux cases pass: Canvas/Modal × app-first/terminal-first. At dispatch the actual +owned xterm textarea and main focus IPC are true; Ctrl+Q opens the real parented Quit dialog. +Native Escape Cancel retains the exact PTY and saved state; native File → Quit / Tab / Enter then +exits naturally with code zero, retires every admitted PTY birth and removes the own hook endpoint. +No signal, `app.exit`, renderer-dispatched key or API Quit supplies a pass. These are four separate private +home/network/PID/Xvfb cases, with zero link cases/URL opens; native macOS/Windows remains unverified. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-native-quit-20261005/`. + +The A122 source repair changes Desktop shortcuts only; Android and external contracts are unchanged. +Beta 16/code 17 remains prepared from `2723845e`, uninstalled; beta 10/code 11 is last installed. +Phone testing stays paused at **10 Pass / 22 Partial / 32 Pending**. The later tmux/SSH and +GPU follow-up below keeps its own source bindings; other-platform runtime remains unverified. +A105/A106 live checks need a usable provider after HTTP 401; +A25/A93 need hosted-backend maintainers. Resume Pixel checks when authorized. No PR is opened, +and A68 remains deferred until requested PR preparation. + +## Tmux, SSH and GPU recovery follow-up (2026-10-05) + +The fresh full Linux Desktop build at `6bfa5412` passes two separate eight-case matrices: +Canvas/Board modal × soft/hard wrap × inner mouse reporting off/SGR. Each matrix delivers +52 native mouse actions and eight complete URL opens through shipped xterm handlers. The local +matrix uses real shipped tmux; the remote matrix uses an actual SSH project, production +SshProjectManager/OpenSSH ControlMaster and remote `nodeterm-rmt` tmux. Exact pane, server and +client births survive every case. SSH retains its connection notice in history and calibrates the +actual viewport offset; the fixture does not erase it. Own session and SSH service cleanup pass. +These fixtures end with explicit app exit and supply no ordinary-Quit or SSH-reconnect proof. +Private proof: `desktop-tmux-matrix-20261005/` and `desktop-ssh-matrix-20261005/` below +`.nodeterm/android-beta-build-16/operation-verification-receipt/`. + +Two separate one-terminal GPU pilots at that same source pass delayed loss → readable stock DOM +→ fresh NVIDIA WebGL2 and quick original restoration → DOM → fresh WebGL2. Both retain the same +49×48 buffer/PTY and three trusted native input acknowledgements, with complete URL interaction +and glyph checks before/after. Quick restoration also exposes A123: the detached original +context remains live after the new renderer is granted. Chromium cap-pressure consequences are +an inference; no page-wide pressure failure was reproduced. + +A123 is source-fixed in `db83fb7d`: retire the captured original context after addon disposal and +before reporting context loss to the budget coordinator. Two new behavioral tests cover detached +canvases and retirement continuing after a throw; all 44 budget tests and 301 affected tests in +seven files pass with full TypeScript checking. Full 44-test control/restored helper runs catch +two exact assertion mutants. Three separate fresh full Desktop builds at `db83fb7d` verify the +actual restore hook: both fixed controls release the original context before fresh regrant, while +the compiled sole-call-removal mutant fails only `GPU_RETIRED_CONTEXT_NOT_LOST` after all three +native input acknowledgements and both complete URL controls pass. A capture-phase native event +witnesses the genuine restoration before production retires it; a passive second-loss event and +DOM/fresh/final checks verify retirement. Fixture probes retain the original context for observation; +no forced GC or second fixture loss supplies a pass. These are one-terminal Linux Canvas pilots, +with explicit fixture app exit rather than ordinary Quit. Private proof: `gpu-context-recovery-20261005/` +for historical recovery and `a123-context-retirement-20261005/` for the new source/compiled mutants. +The older 6bfa and A122 source/build bindings remain separate. + +**Additional Server verification (`fa259f6e`).** Three fresh genuine Linux Node Server/browser +Canvas soft/none runs pass fixed control / sole-call-removal mutant / restored confirmation +with natural fixture exits 0 / 3 / 0. Each authenticates through the shipped HTTP login and WS +bridge, without Desktop preload, and retains the same plain NodePTY, producer, 49×48 +buffer and geometry. Three trusted native input ACKs and two complete URL/glyph controls complete before +the retirement assertion. Fixed runs witness genuine original restoration, production loss of that retired +context, stock DOM fallback and fresh NVIDIA rendering; the calibrated mutant fails only +`GPU_RETIRED_CONTEXT_NOT_LOST`. Owned Server SIGTERM closes it gracefully with zero and removes +its PTY/producer; fixture app exit supplies no ordinary-Quit proof. This is one visible Server +Canvas pilot, not a Server GPU matrix or pressure test. + +**Additional SSH reconnect verification (`fa259f6e`).** One Linux Desktop Canvas soft/none +pilot passes after a single controlled SIGKILL of the exact product ControlMaster. Actual SSH +exit 255 triggers shipped automatic recovery; a separate strictly pinned read-only observer +verifies the same remote tmux pane and server, session-created identity, full history and producer across +the gap and recovery. A new master, local PTY and remote attachment are required. Two trusted +native `g` ACKs and complete URL hover/plain-click/Ctrl-click/leave checks pass with one producer +paint. Exact owned session, observer, master and daemon cleanup passes; explicit fixture app exit +provides no ordinary-Quit proof. Observer reads may affect timing, so this proves no performance +or backoff guarantee. Broader SSH reconnect matrices remain pending; this pilot supplies no +Modal, Server, Android or PAM enrollment proof. +Earlier V2 nonce admission and V3 service-process failures remain fixture negatives; V3's outage +state was not established. Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-ssh-reconnect-20261005/`. + +**Derived Desktop compositor evidence (`db83fb7d`).** A separate hash/CRC-checked analysis of +three existing positive-run `capturePage` PNGs compares an uncovered repeated `A` cell and a blank +cell with the actual framebuffer RGB references. All three match 152/152 pixels in each cell; +42 glyph pixels differ from the blank background. The Sessions-covered top-left cell is excluded, +not a product failure. Recorded geometry establishes 1:1 capture/CSS/GL scales at zoom 1; +a direct device-pixel-ratio query was not recorded. This adds a single-run compositor cell +correlation without rerunning the product or relabelling its old receipts. It does not establish +synchronized frames, whole-frame or physical-display output, or link-underline compositing. + +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/a123-server-context-retirement-20261005/` +contains **80 evidence/recipe files plus its manifest**. Server runtime and historical Desktop +cell derivation remain separate; the original Desktop receipts are unchanged. + +Android and external contracts are unchanged. Beta 16/code 17 remains prepared from `2723845e`, +uninstalled; beta 10/code 11 is last installed. Phone testing remains paused at **10 Pass / +22 Partial / 32 Pending**. Repeated/page-wide GPU pressure, shared glyph eviction, synchronized/ +full-frame compositor or physical-display output, broader SSH reconnect, macOS/Windows and the remaining +Pixel checklist are unverified. Live +A105/A106 needs usable provider authentication; A25/A93 belong to the hosted-backend maintainers. +A68 is source-fixed for branch readiness; no PR has been opened. + +**A68 workflow readiness (2026-10-05).** Source fix `7e91785c` limits Android +push runs to `main` and retains pull-request path filters, without `merge_group`. Feature-branch +pushes no longer duplicate PR checks or automatically prepare beta inputs. Manual +`workflow_dispatch` with `prepare_beta=true` runs all five Android jobs; beta version overrides +and **Private beta checks** are confined to that opt-in. The registered workflow `366764274` +accepts API/CLI dispatch on the selected ref; verify the run's exact head before using its results. +Pre-cutover control [run `37260849252`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37260849252) +was dispatched at `c371860f` with `prepare_beta=true`: all five jobs passed. +That control establishes dispatch admission, not verification of the later A68 source fix. +All 13 focused trigger/path methods pass. All eight actual workflow mutations fail the real +configuration test, with all 13 methods passing before and after restoration. The full +949-method/98-suite protocol gate and offline app compilation are required before push. +After push, exact-head CI is required through `workflow_dispatch` with `prepare_beta=true`; +the verified run and exact head are recorded with the source exports. This closes the source +finding; no PR is opened by this checkpoint. APK/client bytes, private signing, phone pause +and **10 Pass / 22 Partial / 32 Pending** are unchanged. + +## A127 + +**A closed SSH viewer falsely reports that its retained host session ended (2026-10-06).** + +- Severity: **medium**; effort: small; area: phone; kind: bug. +- Status: **source-fixed in `0d50075a`; beta 17/code18 Pixel 7a viewer-only exit and same-producer + manual Reattach/draft input physically verified at `a79375c3`; wider device scope pending**. +- Location: `android/app/src/main/kotlin/dev/nodeterm/android/ui/TerminalController.kt`, + `sinkFor(...).onExit`; `android/protocol/src/main/kotlin/dev/nodeterm/protocol/host/HostConnection.kt`, + `TerminalSink.onExit`. + +On the intended Pixel 7a's beta 16/code 17, terminating only the owned attached tmux viewer +reports SSH exit 1 and displays “The session ended (exit 1).” Native observations show the +original server, exact producer pane/process generation, cwd and history still present. +Manual Reattach opens another viewer onto that same shell. The stream's status does not +establish producer death. Evidence is bound to the +[physical receipt](android.md#pixel-7a-lifecycle-scroll-and-input-follow-up-2026-10-06), particularly +`26-after-channel-drop.xml`, `27-channel-settled.xml`, `27-native-channel-recover.json` and +`channel-interruption.json`. + +The fix uses `TerminalExit.closedMessage`: every numeric exit describes the terminal connection +closing; an unknown exit retains “Disconnected.” It clarifies the stream-exit contract without +changing retry conditions, budgets, generation checks or Reattach. It does not infer liveness +from stale listings or automatically retry arbitrary nonzero failures. + +Three actual JVM methods and offline app compilation pass. A compiled actual callback harness +passes all twelve control/restored cases; three isolated mutants fail assertions, including a +controller bypass of the neutral helper. The old callback baseline fails four assertions. +The harness uses immediate Handler, viewer-slot and retry-admission fakes; it supplies no native +producer-liveness, Android lifecycle or updated APK proof. Independent read-only review finds +no material issues. No wire shape or iOS client change is required. + +The later [beta-17 checkpoint](android.md#pixel-7a-beta-17-upgrade-and-composed-send-2026-10-06) +physically verifies the neutral exit-1 notice after terminating only the captured viewer, Send +being disabled and draft retained. Manual Reattach preserves the exact producer and full `50x40` +native history, then submits that retained draft once. This proves one viewer-only case, +not producer death, arbitrary nonzero retry, transport outage or the full lifecycle matrix. +The final owned cleanup passes; the frozen nine-case/303-raw-hash physical receipt is +`nodeterm-new-phone-fixture-q5v1nuem/proof/physical-pixel7a-beta17/qa-report.json`, SHA-256 +`ece4fa48d31fab55a9f5158f475b4415e9c116c8a2f1683ec48295bf42e55e6b`. +Ordinary native Quit and the wider lifecycle matrix remain unverified. + +Private proof: `/tmp/nodeterm-a127-proof.json`, SHA-256 +`75069aa576ae694cd1b7b8248a9b81c2364882eb37a00d1ccabb93157bf49f67`; +compiled callback recipe `/tmp/nodeterm-a127-verify.py`, SHA-256 +`4956f76e79041e585bf9312395d24ccaa1af341539928f73d3cf3cb850e257cc`. + +## A128 + +**2026-10-08 lost-receipt follow-up (bounded physical pass):** The exact production Enter +receipt reader is killed from its first positive 22-byte read EXIT after one original-shell +execution, with real pidfd/wait-status death evidence. Fresh XML observes the exact newer draft +and disabled Send before loss; the draft remains through automatic original-shell recovery. +One real ACK/witness stays unique over 120 seconds later with the original terminal/producer/ +complete sibling guard held. Owned cleanup completes. Android's internal composed status, +whole-host/network loss, native-route loss and remount/Ctrl remain unverified; no new finding, +source/wire/APK change or original ledger promotion occurs. See +[the measured receipt loss](android.md#lost-send-reply-and-newer-draft-2026-10-08). + +**2026-10-08 retained-receipt follow-up (bounded physical pass):** A fresh beta21/direct-SSH case +observes the exact newer 146-character draft and disabled Send before a measured post-execution +receipt hold ends. The same draft remains with Send enabled after release; one original-TTY +witness/ACK stays unique 168.735781 seconds later with the original terminal/producer/guard held. +Owned cleanup completes. At that checkpoint, lost reply, blackhole, remount/Ctrl and native TLS/history were unverified; +the earlier partial wrapper and original 64-row ledger are unchanged. See +[the measured receipt hold](android.md#held-send-reply-and-newer-draft-2026-10-08). + +**2026-10-08 physical follow-up (bounded partial):** One normal Send and a newer-draft suffix +return before a nine-second pause of the original SSH peer ends. The final required XML observation +misses that deadline, preserving the wrapper failure. Later the exact newer draft remains visible +with Send enabled and one original-TTY witness/ACK stays unique 125.310 seconds later. This is no +strict timed-race, post-write held-result or lost-reply pass; the installed beta21/source `ef4caec2` +and original 64-row ledger stay unchanged. See [the bounded follow-up](android.md#nine-second-send-and-newer-draft-follow-up-2026-10-08). + +**2026-10-07 backend follow-up:** Current local relay Send also routes to the captured direct +native Windows PTY or a session host negotiating `composed-input-v1`. Internal v2 +prepare/write/cancel requests use host-minted one-use tickets bound to the exact live session +generation and subscribed socket, with a 10-second expiry and per-terminal lock across paste +and a separately checked Enter at least 150 ms later. The client checks the captured subscriber +registration and original transport inside its deferred send. A sent-phase receipt loss or +RPC exception stays uncertain without replay; old hosts refuse without restart. Busy/stale +attachment refusals do not request an update. The Android outer action/result is unchanged; +actual producer-to-Kotlin tests accompany the host change. The separate Linux kernel-PTY proof +and native byte-recorder interop do not verify physical Windows ConPTY or phone use of these +backends. See [the source/verification checkpoint](android.md#composed-send-backend-follow-up-2026-10-07-a128) +for final evidence and limitations; iOS availability adoption remains with **@eneskirca**. + +**Composed Send clears a draft without submitting it while tmux history is open (2026-10-06).** + +- Severity: **high (BLOCKS a reliable input release)**; effort: medium; area: input; kind: bug. +- Status: **source-fixed in `7e7d2c04`; beta CI registration in `5484ab9f`; merged 982/102 protocol and app, + affected 187/11/full TypeScript and all-five CI pass at `a79375c3`; beta 17 emacs/vi Send, controlled vi + reconnect/raw Ctrl and exact owned End/cleanup physically verified; wider device cases pending**. +- Location: `TerminalController.submit`, `TerminalScreen` Send, `ComposedCompletion`, + `ComposedPreparation`, `TerminalActions.submit`, `SshHostConnection.SshStream.submitComposed`, + `SshComposedInput`, `RelayHostConnection.Stream.submitComposed`, `PtyManager.submitComposed`, + `core/composed-tmux.ts` and `host-service.ts`'s `pty.submitComposed`. + +Two intended Pixel 7a cases reproduce input loss: composed Send in vi copy mode after a real +SSH transport interruption, and composed Send on a healthy connection in emacs copy mode. +The bar clears, but the exact original shell has no command or marker. In the healthy emacs +case, copy mode remains active at position 70 and the full native capture is unchanged. +The preceding automatic reconnect and history preservation pass; subsequent composed delivery +fails. The receipt keeps the pre-Send capture separate from the actual post-Send capture. +Evidence: `25-unsent-draft.xml`, `30-transport-settled.xml`, +`32-native-after-explicit-send.json`, `35-copy-mode-draft.xml`, +`35-native-copy-before.json`, `36-copy-mode-after-send.xml` and `37-native-copy-after.json`, +bound by the [physical receipt](android.md#pixel-7a-lifecycle-scroll-and-input-follow-up-2026-10-06). + +Installed beta-16 Send returns success when JavaScript is queued, then clears the draft. Native scroll +cancellation removes pending swipes but does not cancel host copy mode; raw stdin reaches the +tmux history viewer rather than the foreground shell. Ordinary raw input, terminal reports and +wheel events cannot globally acquire copy-mode cancellation semantics. + +The source fix awaits an explicit composed action on the captured viewer's input actor. It +stops JavaScript momentum, removes unsent scrolling and orders Send behind the reserved +in-flight scroll. Native attestation binds the attached viewer, server/session and exact +pane/process generation; retirement refuses unsent work before queued SSH detach can run. +Host history cancellation affects only that captured pane. Nonempty paste uses tmux-owned +bracketed framing, followed 150 ms later by a separate Enter with a fresh identity/lifetime +guard. Failure after paste is uncertain. Ctrl remains one raw byte without framing or Enter; +payloads use private stdin/buffers, and no uncertainty replays. + +`ComposedCompletion` accepts one delivered receipt only for the same current viewer. The screen +clears only an unchanged draft revision, including protection against editing away and back to +identical text; a newly rearmed Ctrl survives. Refused, uncertain and stale completions retain +input. `ComposedPreparation` treats a synchronous WebView provider throw as a refusal without +leaving Send busy. Positive delivery means submission to the attested pane/PTY, not command +execution. Direct Unix SSH and current local-tmux relay support this action. Legacy/unverifiable +hosts, native Windows/session-host and SSH-project relay routes explicitly refuse, retaining the +draft without a raw-input or `node.sendKeys` fallback. + +The additive **`pty.submitComposed {streamId, input:{kind, text, enter}}`** returns +`{status:"delivered"|"refused"|"uncertain", message?}`. The Android client and actual +`android/protocol/src/test/interop/host-fixture.ts` land with the host in `7e7d2c04`. +**iOS @eneskirca** needs the same explicit awaited action, exact receipt, paste/Enter separation +and draft-retention policy, including honest old-host refusal. Existing raw frame/report/wheel +semantics are unchanged. + +The frozen candidate passes **170 distinct affected Kotlin methods / 8 suites**, **187 affected +Vitest tests / 11 files**, full TypeScript and forced offline app compilation. Actual isolated +SSH/tmux fixtures cover both copy modes, sibling-pane isolation, raw Ctrl, separately timed +Enter, viewer retirement and uncertainty. All **26 source mutants** (17 Android, nine host) +fail assertions; control/restored runs pass, including the supplementary 42-test host control +with 11 native cases. `5484ab9f` registers the four host suites in private-beta CI. **Nine actual +Gradle/JUnit configuration methods** pass; deleting each registration fails an assertion, with +control/restored checks passing, for **30 assertion-caught mutations** total. +Independent read-only source/evidence review finds no material issues. + +Aggregate V2: `/home/mgysin/.cache/nodeterm-android-work/a128-composed-submit-candidate-elbunqup/a128-composed-review-v2.json`, +SHA-256 `34a0044207b64b5627f0a8653a9969234430a51b697d8ef589220f9e9a26b68f`. +CI registration proof: `/home/mgysin/.cache/nodeterm-android-work/a128-ci-coverage-tzs459s8/proof/receipt.json`, +SHA-256 `6f5b956e6c4ff2d9c0f8a6c070ba429b1cfe8c1175852c56bf30be159c5720f9`. +These source/configuration and isolated native-fixture results remain separate from the later +[beta-17 checkpoint](android.md#pixel-7a-beta-17-upgrade-and-composed-send-2026-10-06). Exact +merged `a79375c3` executes 982/102 protocol and app and affected 187/11/full TypeScript successfully; run `37523703627` +passes all five jobs/ten critical steps. Retained-signer beta 17/code18 is installed with the +exact prepared/installed APK hash and saved temporary SSH profile preserved without pin edit +or key readmission. Bounded emacs and vi copy-mode Send each reach the original producer exactly +once, exit copy mode and leave the guard unchanged. Controlled vi transport recovery retains +the draft with Send disabled, then recovers the same pane and byte-identical full same-grid +capture; its scroll position changes, so exact position preservation is not claimed. One raw +Ctrl+C submits exactly `03`, without another byte in a bounded 0.8-second window. Actual phone +managed shell creation and exact owned SSH End pass; the original guard/canvas-node behavior +is preserved. Only the temporary profile/admitted public key are retired, restoring the +original authorization file; nonce-validated cleanup removes generated private auth and closes +both owned listeners with source/tools/build outputs unchanged. Ordinary Quit is unverified. +The frozen nine-case/303-raw-hash report has SHA-256 +`ece4fa48d31fab55a9f5158f475b4415e9c116c8a2f1683ec48295bf42e55e6b`. +Changed-host-key enforcement, controlled edit/rearm/stale-completion/uncertain-ack races, other +control combinations and provider/device cases remain pending; no full checklist/relay/cellular/ +real-PAM/power-loss/platform or SDK-37 acceptance follows. Installed APK/runtime source stays +`a79375c3`; later docs-only publication gates/CI have separate receipts and imply no reinstall. +The original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. See also the +[source checkpoint](android.md#composed-send-source-checkpoint-2026-10-06-a128). + +## A129 + +**Native history swipes send wheel input to the foreground when application mouse reporting is off** + +- Severity: **medium**; effort: medium; area: input; kind: bug. +- Status: **Source-fixed in `0818bbed` and `2d693263`, published with mandatory gates and + exact-head CI at `1add0408`; physical acceptance pending.** Later genuine Linux Desktop V8/V9 + pilots stop at blank readiness before input. V8's eight-second gate is shorter than supported + cold-create budgets; V9's 30-second gate and complete all-thread snapshots still do not prove + original creation or attachment. [Scoped evidence](android.md#fresh-fedora-output-and-native-desktop-readiness-2026-10-08) + retains both negatives and exact owned cleanup without product attribution or acceptance credit. + The confirmed failure belongs to + `ce1121ba56617defd7facdd44743d416e92b19e4`; its original receipt remains historical. + +On that confirmed source, Android's `terminal.js:435–446` drained every history gesture through +`bridge.onScroll`. `TerminalController.onScroll` and `TerminalActions` ordered that action, then +`RelayHostConnection.scroll` called `pty.scroll`. In `src/main/remote/host-service.ts:638–660`, +the handler assumed a tmux client and wrote fixed SGR wheel bytes through `pty.write` without +checking the backend or current application mouse mode. `PtyManager.write` forwarded them to +`session.proc.write`. For direct native Windows and session-host panes, this was foreground +input, not a retained-history viewport. The native emulators already retained history; its +existence did not make that wheel route browse it. A swipe could therefore feed unexpected +terminal input to an application that had requested no mouse reports. + +The isolated component receipt is +`/tmp/nodeterm-native-scroll-triage-ylpvdie0/receipt.json`, SHA-256 +`c5d50310e539ad02d1c825ac12d19865b4c488184d1d50a8c809aad44b7cbb1a`. +Freshly compiled Kotlin `RelayConnector`/`RelayHostConnection` calls reach the actual approved +E2EE host handler, `NativeWindowsPane` and standalone session-host/client with real emulators. +After synthetic output disables mouse reporting and supplies 200 history lines, scrolling up +three and down two notches records three `ESC[<64;1;1M` and two `ESC[<65;1;1M` writes in each +backend; retained history remains available. The PTY writers are explicit byte recorders and +the host PTY bridge is a component adapter. This is not full Desktop, WebView gesture, kernel +PTY, foreground CLI, physical Windows ConPTY or phone acceptance, and earns no mutation credit. + +Existing tmux and direct-SSH scrolling provenance remains unchanged: those streams attach real +tmux clients with mouse handling enabled. Their custom-binding, gesture and momentum checks do +not verify native-backend history scrolling. A128 composed Send is a separate action and does +not resolve this bug; at that earlier checkpoint, installed beta 17 and the original +**10 / 22 / 32** checklist were unchanged. + +**Published source-fixed implementation.** `pty.attach` advertises `scrollV1:true`; +`pty.scrollV1 {streamId, dir:"up"|"down", lines:1..20, viewId?}` returns `history` with `viewId`, +`offset`, `totalRows`, `cols`, physical `rows`, `olderTruncated`, `hasOlder`, `hasNewer`, or +`input`/`refused`/`uncertain` (a message accompanies refusal/uncertainty). Each row carries +printable text, wrap flag and normal/alternate section. The exact attached stream owns a +**1 MiB/8192-row** snapshot, bounded **256 KiB/200-row** pages, a **60-second inactivity TTL** +and a **32-action FIFO**; the process budget is **16 MiB**. Each notch moves **three physical +rows**. Foreign/expired tokens refuse. A valid token pages stored rows without PTY operations, +including when the live app changes its mouse mode, until Live/user input/resize/new intent +clears it. A new intent without a token reads the actual headless xterm **6.0.0** tracking and +encoding behind output/geometry, with zero native input when off and requested default/SGR/ +pixel encoding when on; unknown state names a refusal. Negotiated `scroll-view-v1` serves +`scrollViewV1 {name, generation, up, lines, capture}` bound to the exact session-host generation +and original subscribed socket. Direct native and known-tmux routes bind their captured viewer; +no background/name-only fallback or uncertain replay exists. +Legacy known-tmux/direct-SSH gestures, copy mode and momentum retain their separate evidence. + +Android keeps the actual shipped xterm **5.5** live parser and displays these rows in a separate +inert layer. Actor/page/display epochs reject stale completions; visible-row Copy and links do +not consult an unrelated live buffer. Host/client/producer interop change together; iOS +@eneskirca must adopt capability, page fields and view invalidation together. + +Current evidence is **34 leaf tests/3 files + 20 assertion-caught mutations**, **343 backend +tests/37 files + four existing Windows-only skips + 21 assertion-caught mutations**, and **13 +root helper/RPC control/restored cases + 18 assertion-caught mutations**. These are actual +emulators and protocol/component/recorder boundaries, not physical ConPTY/kernel PTY/phone +acceptance. A separate startup-cleanup control/restored test and one assertion mutant pass. +Focused Android control/restored runs pass 99 methods/11 suites and offline app checking; its +14 assertion variants comprise 13 behavioral mutations and one posted-Runnable source pin. +Separate CI-reader control/restored checks pass 11 methods and catch eight configuration-input +deletions, not eight more production mutants. The durable Android index +`a129-android-proof-l9kmrcc0/index.json` has SHA-256 +`4ceeaacb141c1fc218644676d4287c15ed325f5d0b29a98fbea3fd36629242af`; the separate CI-reader index +`a129-android-ci8-proof-p6uyz7nw/index.json` has SHA-256 +`f39a53b7b4f37135f923a5019660feade9701d82addbbfebb34fb3d5b82a11fd`. Each layer retains its +own source binding and classifications; no aggregate mixes them or prior attempts. +Published integration `1add0408` passes forced fresh 1012/105 protocol checks with no +failures/errors/skips, offline app, 568/52 affected Vitest tests with exactly four existing +Windows process-tree skips, full TypeScript and all-five/ten-step Android CI `37602046133`, +attempt 1. At that earlier checkpoint, signed beta 18/code 19 was ready and not installed on the intended phone, SHA-256 +`19d55ee177335064b1edb4874b6d27216862d16a7d30ebe476875c566cd263e0`; actual retained-signer +artifact checks use SDK 36, while SDK 37 artifact verification is unavailable. Actual app/WebView +and physical ConPTY/phone acceptance remain unverified. Regular Desktop is not deployed and +no PR is opened. At that earlier checkpoint, installed beta 17/code 18 from `a79375c3` and the +original 10/22/32 physical ledger were unchanged. The separate published +A128 `944223ef` 984/102 protocol, 360/26 Vitest and all-five/ten-step CI `37594743169` checkpoints +are historical bindings, not A129 verification. Source-fixed does not promote the physical checklist. + + +## A130 + +**A held touch on the displayed Live button leaves the previous fling running until click** + +- Severity: **medium**; effort: small; area: input; kind: bug. +- Status: **Source-fixed in `9da36320`, published in `5bda2c32`; mandatory gates/CI pass and + beta 19/code 20 is installed. Actual app/WebView/physical behavior acceptance remains pending.** + The failure below belongs to `1add0408`. + +On confirmed `1add0408`, `android/app/src/main/assets/terminal/terminal.js`'s Live-button `touchstart` +listener stops propagation without calling `cancelScroll`. The terminal host's `touchstart` +listener normally cancels the old fling, but that stopped event cannot reach it. The Live +`click` later calls `cancelInputScroll`, so a held touch continues old scrolling before click. + +The shipped-source probe uses an outside-repo derivative of the existing terminal driver +with explicit child-to-parent event bubbling, stub DOM/xterm/bridge and deterministic RAF. +Original and derivative baseline results match. During a 512-ms held touch on the displayed +Live button, it records **20 additional requests / 23 notches**, matching the no-touch +control, with no new stop callback. Terminal-surface touch stops immediately; clicking Live +then stops and closes history. Receipt `a129-live-held-probe-h2m613ux/receipt.json`, SHA-256 +`b89954c2f959b63df727b986ebcf260e64dac716dd20ef65cb76e0d370d624ea`. + +The original reproduction is source event behavior, not actual WebView, real host transport, +emulator, physical ConPTY or phone acceptance, and earns no mutation credit. + +**Focused source fix.** Live-button touch-down calls `cancelScroll` before +`stopPropagation`. Old momentum and the native pending-scroll queue stop immediately; +history and its display epoch remain intact until click. Three new `TerminalJsLiveTouchTest` +JVM methods cover held/moved touch without a parent gesture, ordinary click/obsolete page, +and cancelled press/current page. Control and restored runs pass **48 methods / 7 suites**, +zero failures/errors/skips; **three semantic mutants** (old handler, premature history close, +parent gesture) fail assertions. Receipt `a130-live-touch-proof-3thll7zv/receipt.json`, SHA-256 +`7eae4fd3f280d945be7dae6544c6569cea9d931b7818dd91e78bceae01be9753`. This focused proof uses +the shipped page through explicit DOM/xterm/bridge stubs and deterministic RAF with a +compiled JVM harness; it does not promote actual WebView or physical acceptance. + +Published `5bda2c32` passes forced fresh 1015 methods/106 suites with zero failures/errors/skips, +offline app/full TypeScript and exact-head five-job/ten-step CI `37608652625`. Retained-signer +beta 19/code 20 is installed on the intended Pixel 7a, SHA-256 +`e7271ebe1c1d65d8f6a09f7c59ab4b0042081c152c421568caabdcf6965bad42`; actual SDK-36 artifact +review `beta19-final-recipes-r5x3c9z6/build/artifact-review.json` hashes to +`9157b0f7b1369c1a1e899c67cb4a82dae973e5eac286f2cf28483f972c202186`. SDK 37 is absent. +[Canonical gates/provider/emulator receipts](android.md#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07) +retain full hashes and scoped negative diagnostics, plus the measured 6.18-second same-signer +update's unchanged first-install time/notification grant/flags, public SSH identity continuity +and one fresh explicit-profile authentication/first pin through an owned ADB reverse tunnel. +Bounded beta-19 known-tmux emacs/vi Send, brief background draft retention, viewer-close/manual +Reattach and controlled SSH-handler recovery pass with exact owned cleanup. At that earlier +`5bda2c32` checkpoint, saved-profile/pin reuse, changed-key refusal and A105/A106 application +remained unverified; the later `e06b5547` phone cases above verify those bounded paths. +A129 native/session-host history and A130 held Live-touch acceptance remain pending. The failed +emulator provisioning and exact owned cleanup add no app/CA/APK proof. A129's earlier `1add0408` evidence and the original +64-item **10 / 22 / 32** ledger remain unchanged. + +## A131 + +**Buffered output and commands waiting for page readiness can outlive their terminal viewer** + +- Severity: **medium**; effort: small; area: phone; kind: bug. +- Status: **Source-fixed/published in `11fbff08`; full gates/CI and beta-20 installation pass at + that earlier checkpoint. Beta21 bounded output/background and controlled SSH recovery/draft/Send + pass; original A132 attribution and wider lifecycle/races remain open.** + +At `644445aa`, `TerminalController` checks the viewer ticket before appending to a shared binary +buffer. Its delayed flush carries no ticket, and backgrounding leaves that buffer scheduled. +An old flush can therefore run after a new viewer paints, and a reader that passed the earlier +check can refill the buffer after retirement. `TerminalPage` also keeps untagged strings while +loading: old paint/output or raw/key commands can survive a reconnect on that same unready page. +These are source ownership findings, not reproduced physical failures. + +`TerminalOutput` checks admission, viewer ownership and a one-use flush reservation under the same +lock. Snapshot admission replaces earlier bytes and gates later bytes behind its paint. Every +viewer retirement invalidates its buffer and callbacks; an ordered current exit flushes its final +snapshot/tail before retirement when the page is ready. The 16-ms batching and 192-KiB binary +chunks remain, including split UTF-8. `TerminalPage` tags viewer commands and drops them on +retirement, preserving page-global font setup and lifecycle suspension. + +Eleven new behavioral methods exercise the actual Kotlin queue/page/slot with an explicit +scheduler and a paused producer. Three separate source pins check the app wiring. The focused +forced offline control/restored passes **71 methods / 8 suites**, zero failures/errors/skips, +including existing page/handoff/actions/composed-input/completion/keyboard-chip coverage. This is component/source +proof; it does not execute Android Handler/WebView or demonstrate a physical race. JavaScript +already dispatched to a ready WebView is outside this fix. Host verbs, pairing, mirrors and +SSH-visible files are unchanged; no producer interop contract changes. iOS implication for +@eneskirca: review equivalent viewer buffer/pending-page retirement, without a wire migration. + +The unchanged queue/page/test's first frozen window passes 32/3 control/restored and catches ten +behavioral mutants. The tightened final controller's separate 71/8 window catches four app +source-pin mutants; the earlier three wiring variants are historical, not extra unique mutations. +[Canonical receipts](android.md#viewer-output-lifetime-source-checks-2026-10-07-a131) bind actual +compiled source, fresh XML and assertion failures. At that earlier beta20 checkpoint, fresh +1029/108/app/TypeScript and all-five/ten-step CI pass on `11fbff08`; the direct-SSH observations +show later rows in a resumed screenshot, but active-production background overlap is unproven. +A132/A133 then require separate completion/exit-display attribution. Later beta21 controlled +SSH recovery, two active Home/resume cycles, draft retention and one explicit Send pass as +[separately scoped](android.md#direct-ssh-reconnect-and-active-background-continuity-2026-10-08); +A133's closed-view checks are separate. Full physical lifecycle/race acceptance is not claimed. +A129/A130 need a trusted relay/native-history route; direct SSH supplies no Live history overlay. +The 64-item ledger remains **10 Pass / 22 Partial / 32 Pending**. + + +## A132 + +**Completion marker absent from native capture after all numbered output rows** + +- Severity: **medium**; effort: unknown; area: verification; kind: gap. +- Status: **OPEN — reproduced bounded observation; product/fixture attribution pending.** + +At signed `11fbff08` on an owned Linux/tmux fixture, one fixed Python command requests 1200 +numbered rows and then `FLOW_DONE`; the final marker's actual emission is unproven. The native observer finds all rows 0000–1199 once and the +original shell owner restored, but `flowDone`/`complete` are false. Independent raw tmux capture +also lacks the completion marker, so this does not establish an Android queue drop. The default +Fedora prompt is a suspected interaction, not a proven cause. Preserve the original negative. +[Canonical receipts](android.md#beta-20-publication-and-bounded-output-checks-2026-10-07) bind the +native result `abdb84f3…` and raw capture `29ed9640…`. + +A later native discriminator at docs-only `656e5d8a` passes two private tmux/Bash cases with +synthetic short and properly delimited long prompts. Both use 2-ms rows, capture all 1200 numbered +rows and the confirmed 24-byte completion write in raw pane/client and captured screen, restore +the same shell and retire the owned server. Private +`a132-native-discriminator-root-ttxny3ik/checkpoint.json`, SHA-256 +`476ff53ed890a9c1fa2657e8b30e8e5cb7e705ee17efcaa5273e2cc53c1f202c`, binds 70 artifacts. +These are not the original Fedora prompt/40-ms command, SSH or phone checks; A132 remains open. + +A fresh normal-Fedora login discriminator at `9b8bba2b` also passes with the unchanged original +226-byte/40-ms producer: all 1200 rows and one standalone marker are present in output-only raw +recording/native capture, with marker bytes in the client raw stream. Existing Bash OSC3008 +reports success, but no producer wait status or final-write witness is added. Current user/hostname, +private cwd and native literal send-keys delivery do not establish historical prompt/Desktop paste +parity. Exact owned cleanup and the independent 108-record review pass; +[canonical evidence](android.md#fresh-fedora-output-and-native-desktop-readiness-2026-10-08) +keeps the original negative and attribution **OPEN**. + +Next: isolate the original completion and prompt ordering across raw PTY/tmux/transport/display. Add a regression only after identifying the failed +supported layer; a successful dispatch or numbered-row count alone must not satisfy completion. +No source fix, Android-only attribution or checklist promotion is claimed. + +## A133 + +**Phone exit display no longer shows previously displayed terminal history** + +- Severity: **medium**; effort: small; area: phone; kind: bug. +- Status: **Source-fixed in `94c8d2c4`, refined/published at `ef4caec2`; final 1033/110/app/TS and CI five/ten pass, beta 21/code 22 installed; bounded same-page retention, final-marker/Unicode clipboard paste and post-font OSC8/plain link offers pass; normal browser/Share pilot now measured as separately scoped; wider matrix unverified.** + +On beta 20/code 21 Pixel 7a through direct SSH to the same owned tmux target, a separate real +phone Send contains `printf 'A131_17930152_MOBILE_FINAL_TAIL\n'; exit 0`. The later screenshot +shows `[exited]` and an exit-0 overlay without the prior displayed history. No byte capture before +exit proves that final marker was emitted; neither its loss nor an Android-only cause is established. +See [the exact screenshot/XML](android.md#beta-20-publication-and-bounded-output-checks-2026-10-07). + +The later native immediate/delayed traces confirm tmux clears the alternate screen before leaving +it, so a leave-only capture is already blank. This change passively captures before `CSI 2 J` and +promotes only a current known SSH/tmux EOF after xterm's parse barrier. Its separate inert closed +pane retains visible rows, wraps, Copy and touch links through layout changes; new viewer/paint/page/ +lifecycle retirement clears it. Blank captures replace stale text. Live TUI/parser behavior and +transport capabilities stay unchanged; there is no Live/input path from the closed layer. +The [local source checkpoint](android.md#closed-sshtmux-exit-display-source-checkpoint-2026-10-07-a133) +records 26 passing actual-xterm component cases, 11 semantic assertion mutants with 23-case +control/restored and one additional 26-case geometry assertion mutant. Permanent behavioral +JUnit and three app source-pin methods pass; final 1033/110/app/TS and exact-head CI pass at +`ef4caec2`, and beta 21/code 22 is installed. Bounded same-page retention/Copy-sheet/link offers +and font redraw pass. The later exact final-marker/Unicode clipboard paste and post-font OSC8/plain +link offers pass. The later [normal browser/Share pilot](android.md#fresh-fedora-output-and-native-desktop-readiness-2026-10-08) +records the address-field nonce/chooser preview; wider lifecycle checks remain unverified. +This preserves the last rendered pane on +one page, not backend full history/unpainted bytes or persistent history across backgrounding. +The original phone final-marker emission remains unverified. iOS needs the equivalent EOF/display +review by @eneskirca; no wire change is introduced. + +The first full run at unpushed `94c8d2c4` exposed an integration regression: refit/font/resize +advanced the legacy submission epoch, dropping a same-view delayed Enter and failing the existing +swipe-barrier test. This follow-up advances that epoch only when the viewer/display clears; +geometry still invalidates pending EOF captures through the separate revision. All 16 focused +methods and the now-required 27 component cases pass. Reinstating the old epoch placement fails +only the new layout/Enter assertion, with healthy/restored controls passing. The linked source +checkpoint records this separately from the earlier mutation proofs. Final source publication, +mandatory gates, exact-head CI and retained-signer beta21 installation pass. The later bounded +Pixel7a case retains eight Unicode rows/wraps/final tail at exit0, exposes normal Copy-sheet +filtering/selection and exact OSC8/plain URL offers, and survives smaller-font redraw. Its +31-record physical review is `beta21-closed-physical-independent-xs0wdxrr/review.json`, SHA-256 +`7145b2cac1c7fbfed39503de5ef00acec8c1bfbce13650bfe6b3b1e5dee364ab`. At that earlier pilot, clipboard +contents and post-font hit tests were not yet verified. The later fresh pilot in the linked checkpoint +verifies exact final-marker/Unicode clipboard paste and both URLs after smaller/restored fonts. +At that copy/link pilot, browser navigation and Share were untested. The later separately scoped +pilot is linked above; unrelated TUI/lifecycle checks remain unverified. +The exact owned target/logger exit naturally; fixture/profile/key/reverse/timeout cleanup is +separately recorded in the linked checkpoint. No full-device or A129/A130 acceptance is claimed. + +## A134 + +**Login-shell executable-resolution probe can outlive its child timeout** + +- Severity: **medium**; effort: small; area: desktop; kind: bug. +- Status: **Source-fixed/published; original create/visible prompt and scoped native capture/reload/input verified; native-history phone/gesture acceptance pending.** + +`execFile`'s timeout sends a signal, while its callback can still await process/stream completion. +The shared PATH/environment resolver previously relied on that callback to settle cached callers. +The earlier passive original-create diagnostic at `70fde6b0` reaches the PATH await and no later stage +within its bound; the actual child's signal/profile/stream cause is unproven. This does not prove +native attachment or a shell absence, and the earlier V8/V9 negatives remain separate. + +The local helper adds an independent five-second Promise fallback, guards settlement once and +ignores late callback values. It kills only its returned child while exitCode/signalCode are null +and releases that child's pipes; no descendant/PID/group search or global termination is added. +Parsing, shared-cache/coalescing, inherited fallback and Windows behavior remain. Focused checks +pass: 37 tests/three affected files, five existing Windows-only skips, seven assertion-caught mutants +with healthy/restored controls and full TypeScript. A separate controlled real-child comparison +also passes. Published `3f382367`/`0d8594a6` pass fresh 1033/110/app/TypeScript, 161 affected tests +with five Windows-only skips and CI five/ten. Fresh V14's original session-host create fulfills and +shows its Bash prompt without PTY input; V13 cause/fallback and native-history/phone acceptance +remain unverified. Every push still requires fresh exact-revision gates/CI. See the +[canonical stage/source checkpoint](android.md#login-shell-probe-completion-deadline-2026-10-08-a134). +This shared Desktop behavior also benefits the iOS companion; @eneskirca needs no wire change. +The installed beta21 artifact and original 64-row ledger are unchanged. + +A separate fresh original-consumer case at immutable build `0d8594a6` and docs-only checkout +`98907171` passes both original factory/kernel admissions before producer input, capture of all +200 unique marker names with Unicode, normal renderer reload and `q`/`r` input/ACK to the same +original shell. The original daemon/state/socket/token/Bash/producer generations and complete +sibling capture stay unchanged; owned retirement is verified. This does not promote Android +native-history/Live, canvas gestures or full rendered payload parity. See the +[scoped consumer receipt](android.md#native-desktop-capture-and-renderer-reattach-2026-10-08). + +## A135 + +**Delayed raw-input consumption clears newly armed Ctrl** + +- Severity: **medium**; effort: small; area: Android input; kind: bug. +- Status: **Source fixed; policy/wiring controls and publication/install pass; phone timing pending.** + +The bridge captures one immutable armed/revision snapshot and consumes only it on its current +viewer. Off/on rearm and stale-viewer callbacks preserve the newer arm. Five policy and three +wiring methods pass; three policy and one wiring mutant are caught. This is source/policy +verification, not a physical race reproduction. No wire change; iOS @eneskirca should compare +its local modifier lifetime. [Full scope](android.md#captured-ctrl-consumption-2026-10-08-a135). + +## A136 + +**Retained terminal navigation entry loses its editor when composition is removed** + +- Severity: **medium**; effort: small; area: Android navigation/editor; kind: bug. +- Status: **Source fixed/published and beta22 installed; one bounded fixed-phone remount pass.** + +The Pixel7a/beta21 composer and checked Ctrl become empty/unchecked after normal PairHost +coverage and Back; both native panes and captures are identical. The corrected harness and +screen-timeout continuation are qualified in the canonical record. Full TextFieldValue/revision/ +Ctrl now live in process memory per actual entry, with whole-stack retention and sealed retirement. +Send clears only its exact revision; pending Controller/speech and process-death restoration are +not added. Nine store and three wiring methods pass; seven policy and one wiring mutant are caught. +A135 is the source dependency. Signed-source publication and beta22 installation pass. One fresh +no-Send PairHost/Back retains the exact draft/checked Ctrl; original native controls remain equal +except a new phone client under the same SSH parent/session/TTY. Owned closure is verified with +the uncertain timeout result preserved and original 15000 read back; raw Ctrl timing, selection/IME +and pending-Send checks remain pending. No wire +change; iOS @eneskirca should compare local editor lifetime. +[Full scope](android.md#retained-terminal-editor-2026-10-08-a136). + +## A137 + +**Retained terminal remount loses pending Send state and outcome guidance** + +- Severity: **medium**; effort: small; area: Android input/navigation; kind: bug. +- Status: **Published at `b9e4cbc7`; beta23/code24 prepared and reviewed; installation/phone comparison unverified.** + +Before the fix, the entry retained its editor/Ctrl, but a replacement controller started without the +old pending state or uncertainty notice. Once reattached it could offer Send for that draft without prior +guidance. This demonstrates an outcome-visibility gap, not a physical duplicate or automatic retry. +The fix shares button/IME admission through one entry-owned identity attempt, preserves a +visible uncertainty notice across remount and later refusal, and seals retired handles. Original +viewer/stream/actor and editor/Ctrl revision fences still prevent stale positive clearing. Twelve +behavior and three wiring methods pass, with ten behavior/three wiring mutants caught. Full1069/116 +and offline app pass; fresh exact-source gates and CI37740459063 attempt 1 pass all five jobs/ten +required steps. Retained-signer beta23/code24/source `b9e4cbc7` passes 46 available SDK36/R8/metadata +artifact checks; actual SDK37 remains unverified. Pre-install snapshot refusal occurred before update +dispatch. Beta22/code23/source `2ba0b142` is last verified installed; guarded installation and the +controlled pending-Send phone comparison remain unverified. A136's no-Send evidence and the original +64-item ledger are unchanged. No host wire or process-death persistence change; iOS @eneskirca should +compare local entry-owned attempt/guidance lifetime. [Full scope](android.md#pending-send-outcome-after-remount-2026-10-08-a137). diff --git a/docs/android-handover.md b/docs/android-handover.md new file mode 100644 index 0000000000..833407eeef --- /dev/null +++ b/docs/android-handover.md @@ -0,0 +1,3434 @@ +# Android companion: handover (updated 2026-10-08) + +Read this first if you are picking up the Android work. It records where the work stands, what has +and has not been verified, what is known to be broken, and the plan in order. The full list of +findings, with evidence and fixes for each, is [`android-audit-2026-09.md`](android-audit-2026-09.md) +(original IDs `A01`–`A77`, continuation findings `A78`–`A137`). The design notes are [`android.md`](android.md) and the user-facing readme is +[`../android/README.md`](../android/README.md). + +## TL;DR + +**Upstream PR conflict integration (2026-10-08).** The Android contribution now incorporates +upstream `main` at `adde5e85` (v0.4.2), retaining the phone's typed host verbs, managed terminal +ownership, SSH-visible files and marker-framed v2 answers alongside upstream chat, role-scoped +pairing and session backends. Android also recognizes upstream Antigravity and matches its +`agy` launch/resume grammar without inventing approval flags. The merged managed hook uses revision 6: older revision-5 hooks +are not admitted for upstream unmarked structured answers, while legacy phone answers keep +their separate contract. The current merge has its own offline, desktop and CI checks; it does +not change the source binding of the prepared beta23 APK or the last verified installed +beta22/code23. Merged-source device acceptance remains pending, and the original +**10 Pass / 22 Partial / 32 Pending** ledger is unchanged. **iOS implications for @eneskirca:** +the upstream chat/role changes and new structured-answer revision gate are shared host behavior; +the existing Android/iOS phone verbs and v2 framing remain compatible. + +**Pending Send after remount (2026-10-08, A137 — published; beta23 prepared).** +Pending Send state and guidance belong to the retained entry, with exact button/IME admission and +uncertainty preserved until dismissal. Source `b9e4cbc7` passes focused66/9, thirteen intended mutants, +fresh1069/116/offline app and CI37740459063 all-five/ten-step. Retained-signer beta23/code24 is +prepared and reviewed with 46 available SDK36/R8/metadata checks; actual SDK37 verification is absent. +A read-only pre-install snapshot refused before any update dispatch. Installation and the controlled +pending-Send phone case remain unverified; beta22/code23/source `2ba0b142` is last verified installed. +The original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. See [the scoped finding](android.md#pending-send-outcome-after-remount-2026-10-08-a137). + +**Beta22 remount follow-up (2026-10-08 — bounded physical pass).** On the Pixel 7a, +retained-signer beta22/code23 (`fa894d4b`, source `2ba0b142`) keeps the exact unsent draft and +checked Ctrl through one normal locally rejected Pair screen and Back. Both original Bash panes, +server/socket, captures and desktop clients stay unchanged; the phone client reattaches under its +same SSH parent/session/TTY. No Send or producer runs. Source `2ba0b142` passes **1054/114**, offline +app compilation and CI37732792493 all-five/ten-step. In-place update preserves measured install +time and full runtime-permission blocks. Owned profile/auth/reverse/fixture cleanup is verified; +raw-input Ctrl timing, selection/IME and pending-Send checks remain unverified. The original +**10 Pass / 22 Partial / 32 Pending** ledger and installed artifact source stay unchanged. + +**Retained terminal editor (2026-10-08, A136 — source fixed; bounded fixed-phone pass).** +The Pixel 7a/beta21 loses its unsent draft and armed Ctrl after another screen covers the terminal +and Back returns. The fix keeps the full editor and Ctrl state with that live navigation entry. +Nine store and three wiring regressions pass; seven policy mutants and one wiring mutant are +caught. Beta22/code23 is published and installed; one retained-entry remount now passes. The +original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged; see +[the scoped evidence](android.md#retained-terminal-editor-2026-10-08-a136). + +**Captured Ctrl consumption (2026-10-08, A135 — source fixed).** Delayed raw-keyboard +consumption now checks the captured Ctrl revision as well as its viewer. Five policy and three +wiring regressions pass; three policy mutants and one wiring mutant are caught. Source publication +and beta22 installation pass; a physical scheduling reproduction remains pending. See +[the scoped evidence](android.md#captured-ctrl-consumption-2026-10-08-a135). + +**Lost Send reply/newer draft (2026-10-08 — bounded physical pass).** A fresh Pixel 7a/beta21 +direct-SSH case loses the original reader's successful 22-byte Enter receipt before it can return +to userspace. The exact newer 146-character draft is observed with Send disabled before loss; +it remains afterward while the app reconnects. One original-TTY witness/ACK stays unique at a +late observation more than 120 seconds later, and automatic recovery attaches a new viewer to +the original Bash/server/socket/producer with the complete sibling guard unchanged. Observed +checkout `5a3013dd`, Desktop build `0d8594a6` and installed APK/source `ef4caec2` are distinct. +Owned cleanup and original 15000-ms timeout restoration pass. Android's internal result is not +observed; network blackhole, whole-host loss, remount/Ctrl during pending Send, native TLS/history +and the full device matrix remain unverified. The original **10/22/32** ledger stays unchanged. +See [the measured receipt loss](android.md#lost-send-reply-and-newer-draft-2026-10-08). +A new real SSH/tmux second-Enter acknowledgement regression passes healthy/restored controls; +its false-success mutant is caught. Production behavior and the installed APK are unchanged. + +**Held Send reply/newer draft (2026-10-08 — bounded physical pass).** A fresh Pixel 7a/direct-SSH +case observes the exact newer 146-character draft with Send disabled before a measured +9.000280-second post-execution receipt hold ends; after release the same draft remains with Send +enabled. One original-TTY witness/ACK stays unique 168.735781 seconds later, with the original +Bash/server/producer/socket/guard retained. Observed checkout `50e7a876`, Desktop build `0d8594a6` +and installed beta21/code22 APK/source `ef4caec2` are distinct; no product or APK change occurs. +Owned phone/host cleanup completes, with original timeout 15000 ms independently confirmed after +an uncertain restore, without replay. At that checkpoint, lost reply, network blackhole, +remount/Ctrl during pending Send and native TLS/history remained unverified; the original +**10/22/32** ledger is unchanged. +See [the measured receipt hold](android.md#held-send-reply-and-newer-draft-2026-10-08). + +**Earlier nine-second Send/newer-draft follow-up (2026-10-08 — bounded partial).** A fresh `d361ce8f` +fixture uses unchanged Desktop/build `0d8594a6` and stock beta21/APK source `ef4caec2`. +One Send and a draft suffix return before the same SSH peer resumes, but the final newer-draft/ +disabled-Send XML observation misses the pause deadline; the original wrapper remains failed. +Later XML retains the exact newer draft and saved pixels show its suffix; Send is enabled and one +original-TTY witness/real ACK stays unique 125.310 seconds later. At that earlier checkpoint, the +strict timed race and lost-reply cases were unverified; the failed wrapper remains preserved. +See [the bounded follow-up](android.md#nine-second-send-and-newer-draft-follow-up-2026-10-08). + +**Silent SSH peer recovery (2026-10-08, Pixel 7a / beta21 — bounded pass).** Fresh owned checkout +`04da9b9e` uses unchanged Desktop/build `0d8594a6` and installed APK/source `ef4caec2`. Only the +admitted old SSH peer is stopped for 45.000 seconds on shared loopback; automatic recovery creates +a new viewer while it is paused, preserving the original shell, producer, tmux server/socket and +guard. The unsent draft and its saved rendered text remain; one later explicit Send executes once +and receives a real ACK. At that checkpoint, whole-host/network loss, cursor position and +lost-reply races remained unverified. Owned cleanup passes; the original **10/22/32** ledger stays +unchanged. See +[the bounded receipt](android.md#silent-ssh-peer-recovery-and-single-send-2026-10-08). + +**Native Desktop continuity (2026-10-08 — scoped verification).** A fresh private Desktop/build +`0d8594a6`, observed with the docs-only `98907171` checkout, passes two original session-host +factories, retained capture of all 200 unique numbered markers with Unicode, one normal renderer +reload and input to the same original shell before/after it. The complete sibling capture and +original daemon/Bash/producer/socket generations stay unchanged; owned retirement is verified. +Android native-history/Live-overlay acceptance, silent network loss and native-route held/lost replies remain open. +Installed beta21 and the original **10 Pass / 22 Partial / 32 Pending** ledger are unchanged. +See [the native consumer receipt](android.md#native-desktop-capture-and-renderer-reattach-2026-10-08). + +**Login-shell deadline (2026-10-08, A134 — published; original create verified).** +Signed, pushed `3f382367`/`0d8594a6` pass forced **1033 methods / 110 suites**, offline app compilation, +full TypeScript and **161 tests in nine affected Vitest files**, with five known Windows-only skips. +[Run `37702852854`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37702852854), attempt 1, passes all five jobs and ten required steps. +A fresh source-built Desktop observes one original Linux session-host create fulfill and display +its Bash prompt, with zero PTY input. V13's child cause and whether fallback fired remain unknown; +native history and its phone overlay remain pending. Installed beta21/source `ef4caec2` and the +original **10 Pass / 22 Partial / 32 Pending** ledger are unchanged. +See [the scoped source checkpoint](android.md#login-shell-probe-completion-deadline-2026-10-08-a134). + +**Direct SSH continuity (2026-10-08, Pixel 7a / beta21).** Desktop `0d8594a6` with the unchanged +stock APK/source `ef4caec2` passes controlled SSH-handler recovery, two active Home/resume cycles, +draft retention and one explicit Send to the original tmux pane. The producer/server/socket and +sibling guard remain unchanged. Phone cleanup is verified; the outer runner stop-accounting +negative is preserved. Native-history/relay/notification and wider lifecycle cases stay open. +See [the bounded continuity receipt](android.md#direct-ssh-reconnect-and-active-background-continuity-2026-10-08). + +**Beta 21 published and installed (2026-10-07 — bounded closed SSH display verified).** +Signed, pushed `ef4caec2` passes forced **1033 methods / 110 suites**, zero failures/errors/skips, +offline app compilation and full TypeScript. [Android run `37678829862`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37678829862), +attempt 1, passes all five jobs and ten required steps. Retained-signer **beta 21/code 22** is +installed on Pixel 7a, APK SHA-256 `91638d05e6192794675dee6a045f53f19cdea707187ebb3482b8ae3f10320120`. +All **46 SDK 36 artifact checks** pass; actual SDK 37 tools are unavailable. The 5.083-second +beta-20→21 update preserves first-install time and the complete notification grant/flags; +the same owned SSH profile and session rows remain visible after the update. +A fresh owned direct-SSH producer completes all 1200 numbered rows and its confirmed 24-byte +completion write in **48.23 seconds**. Actual producer progress crosses Home/resume; later phone +screens show numbered/Unicode rows, the completion marker and older tmux history after a swipe. +The crossing uses receipt/filesystem order, not exact phone-monotonic latency. The original A132 +negative remains separate, with attribution open. A133's same-page exit-0 display retains eight +Unicode rows, wraps and the final tail; Copy-sheet filtering/selection, exact OSC8/plain URL offers +and font redraw pass. A fresh same-page follow-up copies the exact final marker and one Unicode +row through the normal clipboard into the ended composer; OSC8/plain link offers match after +smaller and restored fonts. At that copy/link checkpoint, browser navigation and Share were untested; wider lifecycle/TUI checks remain unverified. A129/A130 native-history acceptance and wider lifecycle/notification checks remain separate. The original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. +The preceding docs-only `9b8bba2b` checkpoint passes forced 1033/110/app/TypeScript and +[Android run `37690764287`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37690764287), +all five jobs and ten required steps. The build and installed APK remain bound to `ef4caec2`; +subsequent documentation publication requires fresh exact-revision gates and CI receipts. +The user authorized ongoing tests without a screen lock: ordinary wake and app launch are allowed, +with no PIN input or lock/security changes. The owned QA profile/public-key line/reverse are +removed, actual timeout readback confirms the original 15000 ms, and the exact disposable host +retires with listeners closed and private authentication removed. Beta21 remains installed. The regular Desktop is not deployed +and no PR is opened. + +**Fresh native diagnostics (2026-10-08; source unchanged).** The `9b8bba2b` normal-Fedora +40-ms run records all 1200 rows and its standalone marker; original A132 attribution stays open. +Linux session-host V8/V9 pilots stay blank before input; V9's 30-second gate/all-thread observations +still do not prove original creation or attachment. A129/A130 acceptance remains pending. See +[the scoped evidence](android.md#fresh-fedora-output-and-native-desktop-readiness-2026-10-08). + +**Browser/Share pilot (2026-10-08; bounded live SSH).** Normal Copy Links Open reaches Vanadium's +exact nonce address and rendered content; Share previews only the selected marker and Back returns +to the owned terminal. Browser Back remains unverified; ordinary resume succeeds. Original +producer/guard continuity and exact owned cleanup pass; Open-focus and timeout uncertainties +are reconciled without replay. +No recipient, closed-page browser-lifecycle, wider matrix or ledger credit is claimed. + +**Earlier beta 20 publication and installation (2026-10-07, A131 — physical checks partial).** +Signed `11fbff08` passes forced **1029 methods / 108 suites**, zero failures/errors/skips, +offline app compilation and full TypeScript; [Android run `37659190980`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37659190980) +passes all five jobs and ten required steps. Retained-signer **beta 20/code 21** is installed on +Pixel 7a; the 4.875-second beta-19→20 update preserves first-install time and notification grant/flags. +A resumed direct-SSH screenshot contains later numbered rows than the earlier capture; active +production during backgrounding is unproven, so no background-delivery pass is claimed. The guard +shows only its own prompt. All 1200 numbered rows remain in native capture, but its completion marker is +absent there too ([A132](android-audit-2026-09.md#a132), attribution pending). After a separate +phone Send/exit, the terminal shows `[exited]` and an exit-0 overlay without the previously displayed +history ([A133](android-audit-2026-09.md#a133), cause pending); final-marker emission was not captured. +A131's focused **71/8** and **10 behavioral/four app source-pin mutants** remain source evidence, +not full lifecycle acceptance. Already-dispatched WebView JavaScript, A129/A130 native history, +wider lifecycle/notification cases and the original **10 Pass / 22 Partial / 32 Pending** ledger +remain outside these observations. See [the bounded receipts](android.md#beta-20-publication-and-bounded-output-checks-2026-10-07). + +**Closed SSH/tmux display follow-up (A133 — published source repair; bounded physical checks pass).** +Native raw exit traces confirm `CSI 2 J` erases the visible alternate pane before `CSI ?1049l`. +This change retains that pane only at an admitted SSH/tmux EOF, as an inert same-page display with +Copy/links; live parsing, scrolling and transport capabilities stay unchanged. **27 shipped-xterm +component cases pass in the permanent behavioral JUnit regression**, alongside **three passing +app source-pin methods** and the existing swipe suite. The first full run caught a same-view delayed +Enter regression; layout changes now preserve it while viewer retirement still cancels it. Its +27-case mutation/control/restored checks pass. Earlier **11 semantic mutants** plus one geometry +mutant and three separate app source-pin mutants remain separately recorded below. Final +1033/110/app/TypeScript gates and all-five/ten-step CI pass at `ef4caec2`; beta 21/code 22 is +installed. Bounded same-page closed-screen, Copy-sheet, exact final-marker/Unicode clipboard +paste and post-font OSC8/plain link-offer checks pass. The later normal browser/Share pilot is +bounded as above; wider lifecycle/TUI checks remain unverified. The earlier beta 20 +came from `11fbff08`, with `656e5d8a` as its later docs-only head. A132's clean native +short/long-prompt controls pass, but the +original Fedora 40-ms/phone completion-marker case remains open. The original 64-row ledger stays +**10 Pass / 22 Partial / 32 Pending**. The equivalent iOS EOF/display behavior needs adoption review +by @eneskirca; no wire change is introduced. + +**Earlier beta 19 checkpoint (2026-10-07 — bounded direct-SSH checks pass).** +Signed, pushed `5bda2c32` includes the A130 held Live-touch fix. Forced fresh offline protocol +checks pass **1015 methods / 106 suites**, zero failures/errors/skips; offline app compilation +and full TypeScript pass. [Android run `37608652625`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37608652625) +passes all five jobs and ten required steps. Retained-signer **beta 19/code 20** is installed +on the intended Pixel 7a, SHA-256 `e7271ebe1c1d65d8f6a09f7c59ab4b0042081c152c421568caabdcf6965bad42`. +The 6.18-second beta-17→19 update preserves first-install time and the notification grant/flags; +actual SDK 36 artifact checks pass and SDK 37 tools are unavailable. Public SSH identity continuity +and a fresh explicit-profile first pin pass through an owned ADB reverse tunnel. Emacs/vi copy-mode +Send, brief background/resume draft retention, neutral viewer-close/manual Reattach and controlled +SSH-handler recovery pass on the disposable Linux host; that earlier owned cleanup passes. +Saved-profile/pin reuse and manual changed-key refusal now pass too; A129 native/session-host +history, A130 held Live touch and the wider lifecycle/provider matrix remain pending. The original +**10 Pass / 22 Partial / 32 Pending** ledger is unchanged. + +**A105/A106 Pixel 7a checkpoint (2026-10-07, Desktop `e06b5547` / beta 19).** Normal Always +allow applies one original `localSettings` rule; real sibling Claude makes two Bash calls without +another hold. One original two-question request submits Cobalt (single) and Lint/Docs (multi), +with real CLI tool/Stop/final-output confirmation. Saved-profile/pin reuse and changed-key refusal +pass, and both owned fixtures and the QA profile/key/reverse retire cleanly. Rule restart-persistence, +production-pane-child launch and the wider matrix remain pending. + +The fresh API-30 emulator boots initially but remains offline +after its one provisioning reboot; its bounded reconnect/screenshot/debugger diagnostics and +owned cleanup add no app/CA/APK acceptance. See [the scoped evidence](android.md#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07). + +**Earlier native history publication (2026-10-07, A129 — physical acceptance pending).** +Signed backend `0818bbed` and host/Android/producer/CI `2d693263` are included in the signed, +pushed integration `1add0408`. They implement +`pty.attach`'s `scrollV1` capability and `pty.scrollV1` for bounded, inert native/session-host +history. A valid viewer token continues paging its immutable snapshot despite live application +mouse-mode changes; Live, actual user input, resize or an explicit new intent clears that view. +A new intent checks the actual backend mode before any wheel input. Source publication and +mandatory verification pass; actual app/WebView, physical ConPTY and phone acceptance remain +unverified. + +Keep the proof layers separate: **34 emulator tests / 3 files + 20 assertion-caught mutants**; +**343 backend tests / 37 files + 21 assertion-caught mutants**, with four existing Windows-only +skips on Linux; **13 helper/RPC control/restored tests + 18 assertion-caught mutants**; and one +fixture-cleanup test with one assertion-caught mutant. Focused Android control/restored runs pass +**99 methods / 11 suites** and the offline app check. Its **14 assertion-caught variants** are +**13 behavioral mutations + one posted-Runnable source pin**. Separate CI-reader control/restored +checks pass **11 methods** and catch **eight configuration-input deletions**; these are not +additional production mutations. No aggregate combines these layers or earlier attempts. +The root helper/RPC receipt is `a129-history-view-proof-v4-67xv3v57/mutation-result.json`, SHA-256 +`fe62c4f299afa9a7254ddb8aaf8e052386e71f7742917565c90363a8fb025583`. +At exact `1add0408`, forced fresh offline protocol checks pass **1012 methods / 105 suites** +with zero failures/errors/skips; offline app compilation, **568 affected Vitest tests / 52 files** +with exactly four existing Windows process-tree skips, and full TypeScript pass. +[Android run `37602046133`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37602046133), +attempt 1, passes all five jobs and ten required steps. Private +`a129-publication-biru7yjl/gates.json`, `ci-result.json` and `final-report.json` bind this source. +The retained-signer **beta 18/code 19** APK is ready and **not installed on the intended phone**, SHA-256 +`19d55ee177335064b1edb4874b6d27216862d16a7d30ebe476875c566cd263e0`. Actual SDK 36 artifact +checks pass; SDK 37 artifact verification is unavailable. These are source/component/recorder +and browser-harness boundaries, not full Desktop/WebView or phone acceptance. The regular +Desktop is not deployed and no PR is opened. +The separate published A128 checkpoint `944223ef` has **984 protocol methods / 102 suites**, +**360 affected Vitest tests / 26 files** and all five jobs/ten required steps green in +[Android run `37594743169`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37594743169). +Its private `a128-publication-1vq7hcgc/gates.json` and `ci-result.json` retain that exact source +binding. At that earlier checkpoint, installed beta 17/code 18 remained from `a79375c3`; no new physical acceptance or change +to the original **10 Pass / 22 Partial / 32 Pending** ledger is claimed. + +**Held Live-button published follow-up (2026-10-07, A130 — app acceptance pending).** +Signed `9da36320`, included in published `5bda2c32`, stops old momentum and the native pending-scroll +queue on touch-down, preserving history and its display epoch until click. Three new LiveTouch +JVM methods pass within **48 methods / 7 suites** in control and restored runs, with zero +failures/errors/skips. **Three semantic mutants** (old handler, premature history close, parent +gesture) fail assertions. Exact-head final gates/CI pass and beta 19/code 20 is installed; +actual WebView/physical acceptance remains pending. The `1add0408` failure remains historical. + +**Composed Send backend follow-up (2026-10-07, A128).** Current source also supports the +attached direct native Windows PTY and a session host that negotiates `composed-input-v1`. +The host owns one-use tickets scoped to its live session generation and the original subscriber +socket; paste and Enter remain separate writes at least 150 ms apart. A lost receipt after input +may have been sent stays uncertain and never replays. Older live hosts refuse without being +restarted. The public Android `pty.submitComposed` request/result is unchanged; its actual +backend producers are tested through the relay and Kotlin client. Windows ConPTY and phone +acceptance of these additional backends remain unverified. At that earlier checkpoint, installed beta 17 still came +from `a79375c3`; that host-side follow-up did not represent a new installed APK. + +**Beta 17 Pixel 7a follow-up (2026-10-06, A127/A128).** Retained-signer beta 17/code 18 +from `a79375c3` is installed on the intended Pixel 7a (Android 17/API 37, +Vanadium `154.0.8037.126.0`) against a fresh disposable Desktop from the same source. +The 5.48-second same-signer update preserves the first-install time and saved temporary SSH +profile; Sessions reopens without editing its pin or readmitting the phone key. One emacs +copy-mode Send submits exactly once to the original producer, exits copy mode and leaves the +sibling guard unchanged. The A127 viewer-only exit now shows a neutral connection-close notice, +disables Send and retains the draft; manual Reattach preserves the same producer and complete +same-grid history, and the retained draft submits once. These are bounded repaired-device cases; +vi copy-mode recovery/Send also preserves the pane and full same-grid history, with the draft +retained and Send disabled during the outage, then one native submission after recovery. One +Ctrl+C action reaches the owned raw TTY consumer as exactly `03`, with no extra byte in a bounded +0.8-second window. Actual phone-managed shell creation and exact owned SSH End pass too: +the producer/pane/viewers end while the sibling guard and expected canvas node remain. +Only the temporary profile/public-key admission are retired; the nonce-validated host stop +removes generated private authentication and closes both owned listeners. Nine bounded cases +and 303 raw-artifact hashes are recorded in the frozen report; ordinary native Quit is unverified. + +At exact merged `a79375c3`, full forced offline protocol checks pass **982 methods / 102 suites**, +zero failures/errors/skips; offline app compilation, **187 affected Vitest tests / 11 files** +and full TypeScript pass. [Android run `37523703627`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37523703627) +passes all five jobs and ten required critical steps. The APK and installed public-code pull +both hash to `c51433d4…`; actual retained-signer verification uses SDK 36, with SDK 37 tools +unavailable. A127 is source-fixed in `0d50075a`; A128 in `7e7d2c04`, with beta CI registration +`5484ab9f` and **30 assertion-caught source/configuration mutations**. Older beta-16 failures +remain historical. This does not complete the 64-item checklist or the original Pixel 10 Pro +ledger, which stays **10 Pass / 22 Partial / 32 Pending**. See [the bounded beta-17 receipt](android.md#pixel-7a-beta-17-upgrade-and-composed-send-2026-10-06). + +**Desktop SSH reconnect checkpoint (2026-10-06, A125/A126).** A125 is source-fixed in `415dae9b`; A126 is committed in `5b7286b3`. +Eight actual Linux Desktop **Canvas/Modal × soft/hard wraps × inner none/SGR mouse** cases +pass at `5b7286b3`. Each loses one birth-pinned product ControlMaster, creates a new master/ +selected local viewer and retains its remote pane/server/producer/cwd and complete same-grid history before, +during, after and at final observation. Both trusted actual textarea `g` inputs are acknowledged +on the old/new PTY: **16 ACKs**, **zero unexpected bytes**, one producer paint per case and +**16 complete URL activations**. Native hover/leave and exact owned cleanup pass. Modal cases +use `80x24`; Canvas cases use `49x48`, with each history comparison bound to its own grid/case. +**294 unique affected tests / 17 files** and full incremental TypeScript pass. **15 isolated +mutants (six A125 + nine A126)** are caught by assertions; each fix's control/restored runs pass. +Forced offline checks at `5b7286b3` execute **950 tests / 98 suites**, zero failures/errors/skips, +and both protocol/app Kotlin compile tasks. These are disposable QA hosts; no new APK or regular +Desktop installation is included. Publication requires fresh offline gates and all five Android +CI jobs for the exact published HEAD; its private bundle and final report carry that run's result. +A separate Linux two-host soft/none case passes at `59e4c93e`: one quiet park/adopt cycle, +inactive B global-card warm reconnect while A stays active and unchanged, **3 native ACKs / 2 +complete URL activations**, retained same-grid history and exact owned cleanup. See [the bounded two-host follow-up](android.md#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06). +The earlier negative/unclassified controls remain historical. Phone release/backend checks still +need their own acceptance; wider parking, Server/cross-window, outage/platform and GPU coverage +remain separate. +Unsupported private shapes preserve input with a warning but may duplicate replies. Android +wire/file/iOS adoption is unchanged. The original Pixel 10 Pro ledger stays paused at +**10 Pass / 22 Partial / 32 Pending**. + +**Fresh-phone checkpoint (2026-10-05, A124).** A separate Pixel 7a has a fresh, +hash-verified, non-debuggable beta 16/code 17 installation (Android 16 / API 36, +Vanadium WebView `145.0.7632.120.0`). Manual explicit-profile, high-port SSH and the existing +terminal's input/cwd/Unicode history and bounded held-drag/coast/new-touch-stop checks pass. +The original **New session → Start** attempt kept default Claude selected and failed shared +shell validation on host `15dd1341`. A124 (`651f46da`) fixes absolute executable resolution. +**The repaired single managed-shell case passes physically on Desktop `40731381`:** actual shell +radio selection, canvas registration, input exactly once, viewer reopen and saved SSH reconnect +after an Android process restart all preserve the same pane and the other original terminal. +Offscreen retained-history Find, selected-line clipboard paste and Copy-sheet URL clipboard +roundtrip also pass. Both disposable QA host profiles were removed through the app; beta 16 and +the phone identity remain. The repaired host stopped cleanly with generated authentication removed +and listeners closed. The original Pixel 10 Pro beta-10 checklist stays paused at +**10 Pass / 22 Partial / 32 Pending**. Fresh-install/single-case results do not verify its update, +live agents, managed End, power loss, QR/relay/cellular routes or the full device matrix. +Dated receipts below remain historical. + +At checkpoint `40731381`, offline protocol tests pass **949 methods / 98 suites**, with zero +failures, errors or skips; **38 affected Vitest tests** pass with five platform skips, full +TypeScript passes, and all three isolated guard mutants fail their expected assertions. +The frozen full Desktop build passes in **29.3 seconds**. The first push has all five Android jobs +green in [run `37375870878`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37375870878), +verified against exact head `40731381c646ccd3425af597b116b5371daf0a63`. Later pushes need their own gates and CI. + +**Native custom-wheel checkpoint (2026-10-06, A86).** A real SSH/tmux regression now verifies +custom copy-mode bindings in both emacs and vi modes, distance across 20-notch chunks and +FIFO-sensitive reversals. All eight settled native positions pass; three deliberately broken +transport variants fail the expected distance assertion. The later Pixel 7a follow-up adds bounded +custom-binding and lifecycle cases; wider gesture/reversal, lifecycle and FPS coverage remains +open. See [the native checks](android.md#native-custom-wheel-checks-2026-10-06) and the physical receipt above. + +**Current source checkpoint (2026-10-05, A100–A124).** The branch adds retained terminal history search, +trusted project env/shell and per-agent launch policy, offscreen Sleeping wake, remembered hook +rules and complete held Claude questions. Direct SSH supports Source Control, selected-profile +Board writes and Desktop wake/refresh/rename. New session can now ask a current Desktop/Server +with an enabled Linux/macOS tmux backend to create and register a managed shell or agent in an +open local folder project (A111); the host resolves its command, account, environment and hooks. +Older hosts retain the relay New flow. The actual producer fixture also exposed and fixed Android CI/local incremental coverage for `src/session-host/**` (A112). Current source also adds truthful legacy revoke outcomes (A113), a dictation language picker (A114), exact connection/record retirement (A115) and a saved LAN/VPN adapter choice (A116). This prepares the upstream Android contribution; no PR +has been opened. Beta 16/code 17 was prepared from `2723845e`, passed the local release checks and is now installed on the separate Pixel 7a. It includes A118's eligible legacy relay identity proof, an explicit SSH profile folder (A119) and one-time password enrollment with fingerprint confirmation and retained-key verification (A120). Current host source also fixes direct-SSH coattachment (A13) and protects relative private HostKey Include reads (A117). +A120 also passes controlled native OpenSSH enrollment, cancellation/deadline and synthetic installed-Fedora-PAM checks; see the later verification receipts. +Current Desktop source also fixes focused-terminal native Quit interception (A122, `9613cec5`); all four Linux native focused-terminal cases pass. +Later checks verify eight shipped-tmux and eight actual SSH-project cases. Separate GPU recovery +pilots exposed and verified A123 restored-context cleanup (`db83fb7d`). A later genuine Server +pilot verifies the same retirement path at `fa259f6e`; separate derived Desktop cell comparisons +use the existing `db83fb7d` captures. A single Desktop Canvas SSH reconnect pilot also passes +at `fa259f6e`; see the final follow-up for their limits. +A92/A121 also pass separate eight-case native Linux Desktop and genuine Server UI checks; +the later receipts preserve both assertion mutants and their source bindings. +The original Pixel 10 Pro remains paused on beta 10/code 11 with +**10 Pass / 22 Partial / 32 Pending**. Fresh Pixel 7a results and the A124 failure are recorded +separately above; the repaired single managed-shell case passes, while live-provider and broader lifecycle/outage checks remain unverified. Immediate FCM +and fresh-different-desktop relay recovery (A25/A93) still need the maintainers' hosted backend +source; the user has no backend checkout. The push adapter points to a separate `nodeterm-server` spec; it was not found in 39 nearby checkouts or the owner’s 22 public GitHub repositories. Existing missing canvas sessions remain attach-only +over SSH. See the audit and Known gaps for unsupported backends and remaining verification. + +**Historical prepared private beta 14 (2026-10-04).** `0.1.0-beta.14` / code `15`, clean built source +`facbbd0384bb5b0ee91656f364793a12f8f41eb1`, adds A113 truthful revoke outcomes, A114 dictation language, A115 exact host/session +retirement and A116 saved pairing/LAN adapter choice. Full offline protocol checks pass +**890 tests / 92 suites**, with zero failures, errors or skips. +Offline app compilation, **1628 affected Vitest tests / 71 files** and full +TypeScript checking pass. Only the ten existing Windows/macOS runtime cases are skipped on Linux. +The minified offline release build passes in **47.96 seconds** with the retained private +signer and R8/runtime keeps. Independent SDK 36/37 signature, nondebuggable manifest, payload/ +provenance and 16-KB ZIP/native checks pass. + +The private archive records **158 assertion-caught isolated mutation variants**, +with passing controls/restored runs and each receipt's source bindings. One additional redundant +retired-guard variant survives equivalently and is recorded separately, never counted as caught. +Historical/intermediate bindings stay separate; full merged gates verify the release source. +Actual local HTTP and producer→Android tests use synthetic OS/key facts; HostStore tests use +in-memory Android interfaces. Native wiring source guards and cached compilation do not prove +physical recognition, VPN reachability, relay revocation or replacement behavior. Earlier beta +proof remains historical. APK SHA-256: `3c75912a5b7bb561fa9a0720be5cb11be75e1672f98379e4745871bf2729f8f6`. Artifact: +`.nodeterm/android-beta-14/nodeterm-android-0.1.0-beta.14.apk`; private proof: +`.nodeterm/android-beta-build-14/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone testing is paused; the ledger stays **10 Pass / 22 Partial / +32 Pending**. No PR opened; A68 stays deferred. Each push requires its own exact-head green +Android workflow. At this APK source checkpoint A13 direct-SSH Desktop detachment, full legacy +identity association and A25/A93 hosted backend dependencies remained open. The later A13 host +follow-up below closes the coattachment source gap; its physical checks remain pending. + +**Historical prepared private beta 13 (2026-10-04).** `0.1.0-beta.13` / code `14`, clean built source +`ba64f6289c0f552b85bfc17f99aee879aaf45975`, includes A111 managed SSH New and A112 producer-input coverage, +along with the beta-12 changes. Full offline protocol checks pass **845 tests / 87 suites**, +with zero failures, errors or skips. Offline app compilation, **1433 affected Vitest tests / +60 files** and full TypeScript checking pass; the ten existing Windows/macOS runtime cases +are explicitly skipped on Linux. The minified offline release build passes in **47.77 +seconds** with the retained private signer and R8/runtime keeps. Independent SDK 36/37 +signature, nondebuggable manifest, payload/provenance and 16-KB ZIP/native checks pass. +The new managed-creation and input-coverage regressions catch **148 isolated behavioral mutation +variants**, with passing controls/restored runs and per-receipt source hashes. Receipts distinguish +assertion failures from the named runtime contract failure; earlier beta-12 proof remains separate. +An isolated native node-pty/Electron/tmux fixture verifies host cwd/account/hooks/env/policy, +one launch submission and uncertainty fences. It uses a fixture CLI; real Claude startup, +macOS native creation and physical power-loss durability are unverified. +APK SHA-256: `faf9e508fa82355f640512ec1861ba886d0e48b8aef3e724edc6435c9f8c134b`. Artifact: +`.nodeterm/android-beta-13/nodeterm-android-0.1.0-beta.13.apk`; build and mutation proof: +`.nodeterm/android-beta-build-13/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone testing is paused; **10 Pass / 22 Partial / 32 Pending** +remains the device ledger. Managed New and the other new source flows require physical checks. +Each push requires its own exact-head green Android workflow; no PR opened, and A68 stays deferred. + +- **Four additions are implemented after the beta-10 checkpoint (2026-10-04).** Sessions search, + live All computers refresh, Find in captured terminal output, and recursive SSH Include anchor + discovery are prepared in beta 11/code 12; full shared checks and the local release build pass; + the Pixel still runs beta 10/code 11. Phone testing remains paused, including final rotation + restoration/fixture Forget and background notification checks. The current device ledger stays + 10 Pass / 22 Partial / 32 Pending; new search/refresh/Include flows need physical verification. + +**Historical prepared private beta 12 (2026-10-04).** `0.1.0-beta.12` / code `13`, clean built source +`b2e41255d8b2cf7bb342b78b7f686b8b0191879e`, includes A100–A110 and the earlier beta-11 additions. Full offline protocol +checks pass **815 tests / 83 suites**, with zero failures, errors or skips. +Offline app compilation, **1314 affected Vitest tests / 53 files** and full TypeScript +checking pass. The ten existing Windows/macOS runtime cases are explicitly skipped on this Linux +host; no additional skip is accepted. The minified offline release build passes in +**52.47 seconds**, with retained private signer, R8/runtime keeps and source/hash provenance. +Independent SDK 36/37 signature, nondebuggable manifest, payload and 16-KB ZIP/native checks pass. +The private support archive retains **236 isolated mutation variants** with passing controls and +restored runs: 228 assertion cases, six named executable-contract failures and two bounded timeouts. +Historical source hashes remain attached to their own receipts; this count does not claim that +all earlier worktrees were identical to the final merged source. Full merged release gates pass. +APK SHA-256: `d5a350a1f7c6f05f1ab476f25cbe58441eb666d61d1a4b62610a3faa2e058e1f`. Artifact: +`.nodeterm/android-beta-12/nodeterm-android-0.1.0-beta.12.apk`; build and mutation proof: +`.nodeterm/android-beta-build-12/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone checks are paused, with **10 Pass / 22 Partial / 32 Pending**; +live Claude rule/question application and the new physical feature matrix remain unverified. +Each requested push still needs its own exact-head green Android workflow. No PR opened; A68 +remains deferred until a PR is requested. + +**Historical prepared private beta 11 (2026-10-04).** `0.1.0-beta.11` / code `12`, clean built source +`314105a435d18e719f53d49bb292d0f3e965ad4a`, includes all four additions (`A96`–`A99`) and the +A95 desktop Board fix. Full offline protocol checks pass **730 tests / 70 suites**, with zero +failures, errors or skips; the offline app compile, **297 affected Vitest tests / 17 files** and +full TypeScript checking pass. The four feature regressions catch **81 isolated mutants**. +The actual offline minified release build passes in **51.78 seconds**, retains the private signer, +and passes the R8/runtime keep and packaging checks. Independent SDK 36/37 signature, +nondebuggable-manifest, payload/provenance and 16-KB ZIP/native checks also pass. The existing +New-session-note layout check passes its control/restored run and catches four additional mutants. APK SHA-256: +`ae052a8a02567c42576012309c5de059827d8b9ff28a9972673af0440d977341`. Artifact: +`.nodeterm/android-beta-11/nodeterm-android-0.1.0-beta.11.apk`; build/regression proof: +`.nodeterm/android-beta-build-11/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed phone installation. Phone checks and final fixture/rotation cleanup stay paused; +10 Pass / 22 Partial / 32 Pending remains the device ledger. These source/build checks do not +verify the new touch/keyboard, multi-host freshness or included-key pairing flows on the Pixel. + +- **Beta 10/code 11 is installed; item 1 paired update passes (2026-10-04).** `0.1.0-beta.10` / code `11`, + clean snapshot `e3ce041c752bdef11b378cf301c719a6e62f70ab`, is installed on the intended Pixel with the + unchanged private signer. The actual release build passes in 52.4 seconds with all R8 keeps; + independent SDK 36/37 signature, 16-KB alignment, payload and provenance review passes. + APK SHA-256: `04ace0ea01540bb477fcf823395a299fa211cfef3745aa28674099038ceb3693`. The 10.14-second + same-signer update preserves install identity, notification grant and the owned SSH second + pane. On beta 9, genuine desktop-issued code text re-pairs the complete original owned desktop + profile and receives relay credentials; safely anchored external SSH refuses, then Automatic + falls back to the actual relay. After updating, that saved + pairing reconnects without re-pairing; the own Terminal1 opens and input reaches its real scoped + PTY. **Items 1/36/51 pass; items 35/49 remain Partial, giving current 10 Pass / 22 Partial / 32 Pending.** Debug-key update/migration + variants remain conditional SKIP on this working private installation. The same owned production + desktop profile now runs repaired build `ec12ea9a`; the original failure was on `071735d6`. Local gates pass 689 protocol tests /67 suites with zero + failures/errors/skips (86.08 seconds wall time including a new daemon), offline app compile + (6.78 seconds), 54 affected Vitest tests and full TypeScript checking. The exact built source `e3ce041c` has all five CI jobs green in [run `37194375778`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37194375778). Each later documentation push needs its own green workflow. Private artifact/proof: + `.nodeterm/android-beta-10/` and `.nodeterm/android-beta-build-10/`; the canonical paired-update receipt is `.nodeterm/android-beta-build-10/paired-update-result.json`. Broader relay/managed, + cellular and the full device checklist remain open; installed A91/A94 full focused empty-host flow passes. + +- **Relay creation, Rename and exact End verified; Refresh dispatch observed (2026-10-04).** On + beta 10, Sessions → New session → Terminal (shell) → Start creates and opens one actual + canvas-registered shell in the exact private project folder. Native input and pwd reach its + scoped bash pane. Rename updates the mounted desktop title without reload. Sessions Refresh + rediscovers both owned rows; the actual owned session-menu Refresh emits exactly one node-refresh + IPC, preserving pane identity and page time origin. Its repaint effect remains unverified. + Exact native End then removes only the adopted node/pane/PID birth; the original producer + terminal remains unchanged. The mounted Board also removes that card without reload. + Item 32's relay plain-shell variant passes; managed-account/cellular and other required variants + remain pending. Item 35 stays Partial for missing Wake and Refresh repaint evidence; item 36 + passes actual repaired-desktop move/remove/re-add/create-label checks (A95). Earlier first-round rotation restoration is historical; final restoration remains pending. The tally is 10 Pass / 22 Partial / 32 Pending; no full release/device pass + or final fixture/service cleanup is claimed. + +- **A95 is fixed and verified on the rebuilt production desktop (2026-10-04).** The held + `071735d6` build reproduced stale mounted Board moves/labels. The same owned paired profile now + runs the repaired `ec12ea9a` production build; Android remains installed beta10/code11 (`e3ce041c`), + with no APK change for this desktop fix. Actual phone Move to Ungrouped, label removal, re-addition + and creation/application of a second blue label update both persisted state and the mounted + desktop Board without a reload. Baseline and every resulting DOM snapshot share page time origin + `1791111312961.1` and match persisted state. This completes item 36. Nine affected Vitest suites + (133 tests), full and strict acceptance TypeScript checks, and four isolated mutants also pass. + The ledger is 10 Pass / 22 Partial / 32 Pending. This verifies the owned production fixture; + no deployment to the user's regular desktop or full release/device pass is claimed. + +- **Checks resumed on 2026-10-04; A93 is an open relay-pairing finding.** Remote-on pairing to + a fresh isolated desktop succeeds locally but receives no relay credential. A bounded + retry using the exact unchanged production mint request body returns HTTP 403 reauth_required. The old fixture was + forgotten, removing its phone-side relay token; the fresh desktop identity differs. + Original same-desktop recovery now succeeds through ordinary full-profile restart/re-pair, + unchanged identity and actual relay credentials. Fresh-different-desktop refusal remains open; + no backend source, copied user credentials or phone-identity reset is involved. iOS recovery + needs verification for @eneskirca. +- **A94 is fixed in 3c217cba and delivered in beta 10; full focused phone flow passes.** The otherwise-empty SSH host remains labelled + “Loading sessions…” on beta 9 despite a completed empty answer, connected SSH and New terminal. + The fix stamps that answer's fetch time. Five actual Kotlin methods and eight isolated mutants + pass; full 689-test protocol/offline app checks and APK delivery pass, with the installed + both sole-End cycles show completed empty/no Loading/row/group with SSH/error retained; + Home recreation opens an actual bash pane between them. Item 1 passes; the ledger is **10 Pass / 22 Partial / 32 Pending**. + +- **`A92`: the recorded desktop file-link gap is fixed in `127b6b28`.** The + capped paragraph now contains the hovered row after long hard/soft wrapped runs. Focused + Vitest 24/24, affected link/dialect suites 54/54, full TypeScript check and three isolated + mutants pass. All eight native Linux Desktop UI cases now pass at `01b4a2f5` with A121's + leave fix; separate eight-case Linux Server checks pass at `dcdf664a`. The later GPU-enabled + eight-case matrix passes (Canvas4 hardware / Modal4 DOM). Those earlier runs did not verify GPU + recovery; later individual pilots are recorded in the final follow-up. macOS remains unverified. + This is Desktop/Server renderer + work only: Android already fixed `A32`, no host contract changes or iOS adoption are owed, + and no APK rebuild or phone-check promotion follows from it. +- **`A91`: empty-host stale rows are fixed in `4d33a5b5` and delivered in beta 9/code 10.** The last phone-shell + End on an otherwise empty SSH host returns `NothingFoundException`; native refresh now clears + that authoritative empty listing while retaining cached rows on other errors and propagating + cancellation. The error and connected SSH route remain. Four new regressions and six isolated + Kotlin 2.2/JDK 21 mutants pass; full protocol **688/67**, zero failures/errors/skips (52 seconds), + and offline app compile (6 seconds). Source `4d33a5b5` built in 43 seconds and updated the + intended Pixel with the retained signer in 6.46 seconds; its installed APK hash matches. + Three owned beta-8 shells survived the update and later verified cleanup. On Oct4, the + otherwise-empty-host first Home creation/exact last-End/row-and-group removal/connected SSH + and second Home creation pass. That second shell survives beta 10's update, followed by + the full A91/A94 End/Home recreation/open/End empty-screen cycle. Final fixture/service cleanup + is not yet claimed. +- **`A90`: explicit plain SSH-terminal creation is implemented and host-verified.** Protocol + `bcc92367` and UI/model `b88d1415` add the path. The manual + SSH/WireGuard host has driven projects but no own desktop workspace. Create a shell in a + discovered host folder or Home on dedicated `nodeterm-phone`; rediscover it under Phone + terminals and end its exact owned session. No shared project-file/canvas writes or managed + agent identity; desktop/Server Edition scans remain on their own two sockets. Cold-agent + `A08` refusals and relay canvas New session stay unchanged. Installed beta 10 / code 11 contains + it; focused Pixel Home/project/custom creation, history/restart/update/reconnect and exact UI + End pass. Item 32 is Partial; relay plain-shell creation/input/End pass, managed and cellular creation stay open. A91 empty-host flow passes on Oct4. + Beta-6 physical proof is historical, + and beta 7 / code 8 remains a prepared, unused candidate. +- The private minified beta 10 is installed on the intended Pixel 10 Pro (Android 17 / + API 37), with a retained private signer. Historical proof retained through beta 6 covers manual + SSH listing of 17 real projects, input, font/keyboard resizing, pre-attach tmux history and + user-confirmed terminal access over mobile-data WireGuard with Wi-Fi off. The wrong-MI8 + installation and its newly authorized SSH key were removed. +- **Beta 6 verifies continuous dragging, coast, Esc and new-touch stopping on the Pixel (`A86`, `A89`).** + SSH reopened with its retained key/pin at 56×48. A slow controlled swipe produced 23 position + updates; a fast swipe kept moving for about 799 ms after the ADB command completed. Esc left + copy mode and remained out for 1.4 seconds. `c4b1f6cf` fixes beta 5's detached text-span touch + target by targeting the stable screen. A held touch stops coast in a stable 56×25 keyboard-open + viewport; earlier resizing tap checks were inconclusive. The user confirms normal dragging and + coasting both work now. The final beta-6 cellular WireGuard SSH check with Wi-Fi off confirms + connection and smooth scrolling. + Remaining lifecycle checks, FPS and custom bindings remain open. +- **Requirement-audited real Pixel checklist QA:** complete passes are **1/18/19/21/22/24/36/38/39/51**. + All sending chips, software input, font changes and rotation preserve SSH. Unicode renders; + 90000 clipboard characters match exactly, 150000/450000 are refused, and invalid OSC52 is silent + with clipboard preserved. All three keyboard-focus states send real software input to the pane. + Background process kill restores Inbox/tab/back stack; the shipped managed hook passes + Approve/Deny/expiry with a synthetic producer (no live Claude CLI/account or requested Bash execution). + Seventeen sending chips plus four app-mode arrows and font/rotation complete item 18; + links/Copy sheet and SSH detach/recovery add partial evidence. Landscape + with the IME open is 129×1, not a verified usable-height case. The [64-row table](android.md#what-is-verified-and-how) + records ten Pass, 22 Partial and 32 Pending items after item 1 paired-update, focused creation, partial Rename and item 36 Board verification, with conditional + SKIP variants. The earlier 7/20/37 checkpoint is preserved. Those + earlier checklist results used beta-6 source `c4b1f6cf`; current installed beta 10 uses `e3ce041c`. + Recorded prior all-green branch CI `19da35a2` has all five jobs green in + [run `37140762345`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37140762345), including A90. + Source run `37144282865` failed the documentation checklist mapping; debug/release APK and + CodeQL jobs passed, private packaging was skipped. The next push needs its own green workflow. +- The user resumed remaining Pixel release checks on 2026-10-04 after the hike. Item 18's Pass comes from + reviewing existing proof against its written requirement, with no new phone work. Item 1 now + passes desktop-issued pairing/relay credentials through the same-signer code10→11 update. +- Historical private `0.1.0-beta.8` / code `9`, clean `b88d1415`, was **installed on the exact intended Pixel** + via a 28.05-second same-signer update. Before/after pulled APK hashes and the public signer match; + metadata is code 9/name beta 8/non-debuggable, `firstInstallTime` and notification grant remain, + and the app starts with its existing manual-SSH computer row. Opening that host reconnects over + SSH and lists real driven projects; New terminal is visible/enabled. No terminal was created or + ended; actual creation/persistence/End remain pending. Build/R8/artifact checks pass. After the hike, pair on current + beta 9, then verify pairing/relay credential survival across a later same-signer higher-code + update for item 1. Keep the working app installed. Beta 7 remains prepared, unused. The tally remains + seven Pass / 20 Partial / 37 Pending. Debug migration stays conditional SKIP on this working Pixel. +- **Live encrypted paste pairing and relay-only browsing work.** The actual Pixel pairs from + PairingService JSON with remote access enabled, then opens the empty isolated production + desktop through the hosted relay. The current `/v1/relay/join` request/response is verified + beyond interop. Desktop source `58a202be` uses a private bwrap home and real SecretService + encrypted credential storage; no user credentials were copied. This approved-at-pairing path + expects no SAS prompt. Relay terminal attach/echo input to an owned real PTY also pass; the node + was created through production preload/Canvas events, not phone New session. QR/scanner, + cellular relay, SAS denial/revoke and remaining node actions stay open. +- Mounted relay single/multi-select and copy-mode questions now have actual synthetic-application/ + shipped PostToolUse/mirror proof; item 41 is Partial. Offscreen/released/direct-SSH/target-guard + device variants remain open. Cleanup forgot only the fixture host, stopped owned resources and + returned the phone to regular Sessions. The user confirms beta 6 / code 7 connects and scrolls + smoothly there over cellular WireGuard; hosted cellular relay remains untested. +- The A90 baseline checks pass **684 protocol tests in 66 suites, zero failures/errors/skips** (48 seconds), + plus offline app `compileKotlin` (1 second). Eleven actual Gradle/Kotlin 2.2 protocol behavioral, + twelve helper behavior and nine native wiring mutations are caught (32 total). Historical scroll + checks caught thirty-five JS, eleven native and three CSS mutations (49 total), including the + strengthened new-touch regression's seven kinetic tests. + `A88` (`f5fd3821`) now reproduces SDK 37's actual `V3.0 Signer:` format; 39 Python tests pass against each + real SDK 36/37, and ten new parser/fixture-selection mutations are caught. CI run `37058184031` + passed its four main jobs but failed private packaging. Follow-up [run `37061593216`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37061593216) + at `c37798b6495b4b68df379d0ae80887c23104b66d` completed with all five jobs green, confirming + the observed packaging workflow repair. Each later push still requires its own green workflow. +- Private `0.1.0-beta.6` / code `7` uses source `c4b1f6cf1009f293a658b6331d2ed1ab80aa36c6`. + Its actual AGP release built in 43 seconds and passed every R8 keep. Retained-signer packaging + verified non-debuggable metadata, signature, 16-KB alignment and source/hash provenance; all + 149 ZIP payloads stayed unchanged by signing. APK SHA-256: + `4947133a6ccf9c2b1e775e76d7c24f564e087cf036e59eac4dca08162a076d3c`. + The intended Pixel received a same-signer update preserving app data; notification permission + is confirmed. SSH reopened with its retained key/pin, and controlled continuous drag/coast/Esc + checks pass. New-touch stopping passes at a stable 56×25 viewport; lifecycle stopping, + FPS and custom wheel bindings remain open; normal drag/coast have earlier user confirmation. + The final cellular WireGuard SSH check confirms connection and smooth scrolling. + Private proof is in `.nodeterm/android-beta-build-6/`, artifacts in `.nodeterm/android-beta-6/`. +- Historical beta `0.1.0-beta.4` / code `5` built locally from + `3cffb49d8cf64932260e914b42b3883331d0352d`, using the retained signer. + The code-5 local AGP release built in 48 seconds and passed every R8 keep. Retained-signer + packaging verified non-debuggable APK metadata (minSdk 26, targetSdk 35), signature, alignment + and source/hash provenance. APK SHA-256: + `c64d6a8dea9621265f23a679104149e511ecd243fbdfa53ced401f4cb4f0f6b5`. + The intended Pixel updated in place, preserving manual SSH configuration/key/pin and granted + notifications; the installed app remains non-debuggable. Its owned 56×48 terminal now reaches + positions 25, 45, 70, 100, 130 on the same five 1000-native-pixel/350-ms swipes, versus beta 3's + 5, 10, 15, 20, 25. Each gesture produces 4–6 observed host-position changes, reversal moves + 130 to 110, and the actual Esc chip leaves copy mode. Private inputs/proof are in + `.nodeterm/android-beta-build-4/` and the APK/checksum/metadata in `.nodeterm/android-beta-4/`. + These verify delivered movement/order/cancellation, not terminal FPS or satisfactory user feel. + At the beta-4 checkpoint the user reported missing momentum; beta 6 now has controlled and + user drag/coast confirmation. Custom wheel bindings and the remaining `A86` checks stay open. +- Of the original 77 audit findings, 74 are fixed after the request-owned A56 follow-up. + `A25` still needs backend FCM, `A50` has signed delivery with full validation open, and `A68` + waits for a requested PR. + QR pairing, cellular relay/SAS denial/revoke, actual outage/notification/answer behavior and + the full 64-item pass remain open. + Push is authorized; required checks precede each push and its Android workflow must be green. + No PR should be opened unless asked; `A68` is last. + +## Progress log + +### Upstream PR conflict integration (2026-10-08) + +Merge upstream `adde5e85` into the existing contribution branch without rewriting its published +commits. Resolve Android ownership/interop behavior alongside upstream v0.4.2 chat, pairing roles +and session backends. Keep new structured answer support on revision 6 because both parents used +revision 5 for different formats. Source regression and offline gates precede publication; +merged-source device checks remain next, with beta23's earlier source binding preserved. +No new finding or device-ledger promotion is claimed by conflict resolution. + +### Pending Send outcome after remount (2026-10-08, A137) + +Source tracing confirms that retained editor state outlives controller-local pending/outcome state. +The entry-owned attempt/guidance fix preserves original viewer/revision fences and uncertainty +across a later known refusal. Focused66/9, full1069/116, offline app and thirteen intended mutants +pass. Source `b9e4cbc7` is published with fresh1069/116/offline app and CI37740459063 five/ten passing; +retained-signer beta23/code24 passes 46 available artifact checks. Pre-install snapshot refusal means no +update was dispatched; beta22 is last verified installed and the controlled phone comparison remains +unverified. See [canonical scope](android.md#pending-send-outcome-after-remount-2026-10-08-a137). + +### Beta22 publication, update and bounded remount (2026-10-08) + +Source/Desktop `2ba0b142` passes fresh 1054/114/app and CI37732792493 all-five/ten-step. +Same-signer beta22/code23 installs in place with measured install time/full runtime grants held. +One fresh no-Send PairHost-cover/Back retains the exact draft and checked Ctrl; original native +controls stay equal except the qualified phone-client reattach. Owned cleanup and read-only 15000 +timeout reconciliation pass, with the uncertain original restore result preserved; see +[the canonical scope](android.md#retained-terminal-editor-2026-10-08-a136). + +### Retained terminal editor (2026-10-08, A136) + +Original Pixel7a/beta21 draft/Ctrl loss is reproduced; own native panes/captures remain unchanged +and the fixture/profile/auth/reverse are retired. Full entry-retention/revision/retirement source +and mutation controls pass. [Canonical evidence](android.md#retained-terminal-editor-2026-10-08-a136); +beta22/code23 publication/update and one bounded remount pass; broader checks remain pending. + +### Captured Ctrl consumption (2026-10-08, A135) + +Immutable revision/viewer consumption is source fixed; policy and wiring mutation controls pass. +[Canonical evidence](android.md#captured-ctrl-consumption-2026-10-08-a135); source gates/CI and +beta22 installation pass; physical raw-input scheduling remains pending. + +### Lost Send reply and newer draft (2026-10-08) + +A fresh Pixel 7a/beta21 direct-SSH case loses the successful Enter receipt after one actual +execution. The exact newer draft and disabled Send are observed before loss, the draft remains +through automatic reconnect, and one original-TTY witness/ACK stays unique over 120 seconds +later. The original terminal/producer/whole sibling guard remain unchanged. Owned phone/host +cleanup and the original 15000-ms timeout restore pass. Android's internal composed status, +blackhole, whole-host loss, native-route loss and remount/Ctrl remain separate. +One real SSH/tmux regression loses only Enter's exit acknowledgement after execution; the +healthy/restored controls pass and a false-success mutant fails the intended status assertion. +Production source is restored exactly; the test addition requires fresh full protocol/app gates. +See +[the measured receipt loss](android.md#lost-send-reply-and-newer-draft-2026-10-08). + +### Held Send reply and newer draft (2026-10-08) + +A fresh Pixel 7a/beta21 direct-SSH case passes the measured post-execution receipt hold: +the exact newer draft and disabled Send are observed before release, then the same draft remains +with Send enabled. One original-TTY witness/ACK stays unique 168.735781 seconds later, with the +original terminal/producer/guard retained. Owned cleanup completes; original timeout 15000 ms is +confirmed after an uncertain restore without replay. At that checkpoint, lost reply, blackhole +and remount/Ctrl cases remained open. See [the measured receipt hold](android.md#held-send-reply-and-newer-draft-2026-10-08). + +### Nine-second Send/newer-draft follow-up (2026-10-08) + +A fresh `d361ce8f`/Desktop `0d8594a6` fixture with unchanged beta21 returns one Send and a suffix +input before its original peer resumes. The final new-draft/disabled-Send observation misses the +nine-second deadline, so the wrapper remains failed. Later the exact newer draft and visible suffix +remain with Send enabled; one original-TTY witness/ACK is still unique 125.310 seconds later. +Owned cleanup completes; that earlier case verifies no strict timed race or lost reply. See +[the bounded follow-up](android.md#nine-second-send-and-newer-draft-follow-up-2026-10-08). + +### Silent SSH peer recovery (2026-10-08) + +A fresh `04da9b9e`/Desktop `0d8594a6` fixture and unchanged beta21 recover automatically while +one old peer is paused for 45 seconds. Original shell/producer/server/socket/guard survive; the +unsent draft remains in XML and saved pixels. One later Send has one witness/real ACK, still unique +92.719 seconds later. Owned cleanup passes. Image-preview blankness was disproved by actual PNG +pixels, so no new rendering finding is established. Whole-host/network loss, cursor position and +lost replies remain untested. See [the bounded receipt](android.md#silent-ssh-peer-recovery-and-single-send-2026-10-08). + +### Native Desktop consumer continuity (2026-10-08) + +Fresh immutable Desktop/build `0d8594a6`, with docs-only checkout `98907171`, now verifies +two original native factories, both original producer lineages before capture, 200 retained +unique marker names/Unicode, normal renderer reload and exactly one `q`/`r` input/ACK to the +same original target. The sibling producer records no input and its whole capture is unchanged. +Source/tools/outputs hold and owned retirement is verified. Android native-history/Live, +canvas gestures and full rendered payload equality remain unverified; installed beta21 and +the 64-row ledger are unchanged. See the +[native consumer receipt](android.md#native-desktop-capture-and-renderer-reattach-2026-10-08). + +### Direct SSH continuity (2026-10-08) + +Desktop `0d8594a6` with the unchanged stock beta21 APK passes a controlled handler drop/automatic +recovery, two Home/resume cycles during active output, retained draft and one explicit Send. +Original producer/server/Bash/socket and the entire sibling guard stay unchanged. Phone cleanup +is verified; the supervisor stop and outer runner accounting negative stay separate. This does not +cover silent network outages, held replies or the native-history relay route. See the +[bounded continuity receipt](android.md#direct-ssh-reconnect-and-active-background-continuity-2026-10-08). + +### Login-shell completion deadline (2026-10-08, A134) + +The signed source/caller checkpoints `3f382367`/`0d8594a6` pass forced 1033/110/app/TypeScript, +161 affected tests with five Windows-only skips, and all-five/ten-step CI run `37702852854`. +A fresh source-built V14 diagnostic observes one original session-host create fulfill and its Bash +prompt without PTY input; exact owned retirement is verified. V13's child cause/fallback remains +unknown. Native history and its phone overlay stay pending; installed beta21 is unchanged. See the +[scoped source checkpoint](android.md#login-shell-probe-completion-deadline-2026-10-08-a134). + +### Fresh native diagnostics and Browser/Share pilot (2026-10-08) + +At unchanged `9b8bba2b`, the normal-Fedora discriminator records all rows and its marker while +original A132 attribution stays open; V8/V9 native readiness remains unclassified before input. +Installed beta 21/source `ef4caec2` opens the nonce URL in Vanadium and previews the selected Share +marker. Browser Back remains unverified; ordinary resume and chooser Back succeed. Exact owned +cleanup and original-timeout readback pass; Open-focus/restore uncertainties are retained without +replay. No A129/A130 or 64-row ledger promotion. See the +[scoped receipts](android.md#fresh-fedora-output-and-native-desktop-readiness-2026-10-08). + +### Beta 21 clipboard and post-font link pilot (2026-10-07) + +A fresh same-page ended SSH/tmux pane copies the exact final marker and one Unicode row through +normal clipboard paste into its disabled-Send composer. Both full OSC8/plain URLs are offered +after A-minus/A-plus changes; opening the sibling guard removes the old closed display. Exact +fixture/key/reverse/profile cleanup passes. The timeout restore has an uncertain post-write check; +guarded readback confirms the original 15000 ms without another write. Browser/Share/wider +TUI/lifecycle/native-history checks and the 10/22/32 ledger stay unchanged. See the +[bounded receipts](android.md#closed-sshtmux-exit-display-source-checkpoint-2026-10-07-a133). + +### Beta 21 publication and closed SSH display (2026-10-07) + +Signed `94c8d2c4` retains the last visible SSH/tmux pane at an owned EOF; `ef4caec2` preserves +same-view delayed Enter through layout changes. The 27-case renderer regression, focused +control/restored mutation checks, forced 1033/110 protocol, offline app/full TypeScript and +five-job/ten-step CI pass. Retained-signer beta 21/code 22 is installed. A fresh 1200-row/40-ms +output case crosses actual background/resume and reaches the phone with its completion marker. +A separate natural exit retains Unicode/wraps/final text; Copy-sheet filtering, original link +offers and font redraw pass. A later fresh same-page pilot copies the exact final marker and one +Unicode row into the ended composer and offers both exact URLs after smaller/restored fonts; +browser navigation, Share and the wider matrix remain unverified. All temporary phone/host admissions retire cleanly. The original A132 attribution and +10/22/32 ledger remain unchanged; see the [bounded receipts](android.md#closed-sshtmux-exit-display-source-checkpoint-2026-10-07-a133). + +### Beta 20 output/background checks (2026-10-07) + +Signed `11fbff08` passes fresh 1029/108, app/TypeScript and five-job/ten-step CI; retained-signer +beta 20/code 21 installs with unchanged first-install time and notification grant/flags. The +resumed screenshot contains later rows and keeps the sibling guard scoped; active-production +background overlap is unproven. A132 records the missing +completion marker in native capture; A133 records history absent after exit without claiming final +marker emission or an Android-only cause. Host/profile/public-key/reverse cleanup passes; the +timeout is already the original 15000 ms, with no restore write. The original 10/22/32 ledger +is unchanged. See [the canonical receipts](android.md#beta-20-publication-and-bounded-output-checks-2026-10-07). + +### Pixel 7a A105/A106 and changed SSH key (2026-10-07) + +At Desktop `e06b5547` / beta 19, original remembered-rule and two-question Send answers choices +pass real sibling CLI application. Saved-pin reuse and manual changed-key refusal pass too. +Both fixtures and the QA profile/key/reverse retire cleanly, with the original timeout restored. +[Receipts and limits](android.md#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07) +retain the unchanged original 10/22/32 ledger; publication requires fresh exact-revision gates/CI. + +### A105 real Desktop remembered-rule application (2026-10-07) + +At `41406fef`, real Claude 2.1.292 in the sibling namespace uses the original captured node +environment and unchanged production hook. The shipped Desktop consumer chooses one original +`localSettings` rule: nine hooks, one consumed/removed hold, two Bash executions without a second +hold, and the exact CLI-saved allow rule; 8.648 seconds/$0.0166058. CLI and owned-host cleanup pass. +At that earlier checkpoint, no phone input or production-pane-child launch was claimed; Android A105, A106 and wider persistence +remained pending. [Canonical receipts](android.md#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07) +retain the result and independent review. The original 10/22/32 ledger is unchanged. + +### Beta 19 publication, provider inference and failed emulator calibration (2026-10-07) + +Signed `5bda2c32` publishes A130 with fresh forced 1015/106 protocol, offline app/full TypeScript +and all-five/ten-step Android CI `37608652625`; retained-signer beta 19/code 20 is installed +with SDK-36 artifact verification. Its 6.18-second same-signer update preserves first-install +time and the notification grant/flags. The displayed public SSH identity matches its earlier +identity, and one fresh explicit-profile direct-SSH authentication pins the expected disposable +host key through an owned ADB reverse tunnel. Emacs/vi Send, brief background/resume draft +continuity, viewer-close/manual Reattach and controlled SSH-handler recovery pass with original +producer/guard/history retained; exact owned profile/key/route/timeout/host cleanup passes. +At that earlier checkpoint, saved-profile/pin reuse, changed-key refusal and actual A129/A130 native behavior remained pending. +At that earlier checkpoint, one normal authorized tool-free Claude inference passes in +3.865 seconds/$0.002988, while A105/A106 live application remains unverified. The owned API-30 +emulator initially boots but remains offline after one provisioning reboot and one scoped +reconnect. The console responds, the framebuffer request times out and one debugger capture +detaches; none establishes app readiness. Exact supervisor/children/ports cleanup passes with base images and existing AVD +metadata unchanged. [Canonical raw receipts](android.md#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07) +keep these layers separate. No app acceptance or ledger promotion is claimed here. + +### A129 native history implementation (2026-10-07, published source; acceptance pending) + +At `ce1121ba`, actual Kotlin/E2EE host calls through native and session-host emulators reproduce +five foreground SGR wheel writes for an up-three/down-two history scroll with mouse reporting +off. The native writer boundaries are recorders and the host PTY bridge is a component adapter; +there is no full Desktop, WebView gesture, kernel PTY, physical ConPTY or phone claim. +Receipt `/tmp/nodeterm-native-scroll-triage-ylpvdie0/receipt.json`, SHA-256 +`c5d50310e539ad02d1c825ac12d19865b4c488184d1d50a8c809aad44b7cbb1a`. +[A129](android-audit-2026-09.md#a129) is **source-fixed and published** at signed `1add0408`, +including `0818bbed` and `2d693263`. Fresh mandatory gates and exact-head CI pass; actual +app/WebView and physical acceptance remain unverified. Advertised `pty.attach.scrollV1` +enables `pty.scrollV1` bounded +history pages scoped to the exact stream; existing tokens page immutable inert rows until +Live/input/resize/new intent closes them. New intents check actual headless mode after queued +output and follow the requested encoder, with exact backend generation/subscriber/socket +fences and no replay. Android's separate layer keeps the actual xterm 5.5 live parser and +rejects late pages through actor/page/display epochs; Copy/links use displayed rows. +The contract, resource limits and iOS @eneskirca adoption are +[documented together](android.md#native-history-scrolling). +The top checkpoint records passing leaf/backend/root and focused Android checks separately +from fresh 1012/105 protocol, offline app, 568/52 Vitest with four existing Windows skips, full +TypeScript and all-five/ten-step CI `37602046133`. At that earlier checkpoint, signed beta 18/code 19 was ready and not installed on the intended phone. +Existing tmux/direct-SSH scroll evidence, installed beta 17 and the original 10/22/32 ledger +are unchanged; regular Desktop deployment and a PR are not part of this checkpoint. + +### A130 held Live-button touch (2026-10-07, focused source fix) + +At `1add0408`, the shipped-source/stub/RAF probe shows a displayed Live-button touch held for +512 ms leaving the previous fling active: 20 further requests / 23 notches, matching the +no-touch control. Terminal-surface touch stops immediately; the Live click subsequently stops +and closes history. This original failure remains historical. Receipt +`a129-live-held-probe-h2m613ux/receipt.json`, SHA-256 +`b89954c2f959b63df727b986ebcf260e64dac716dd20ef65cb76e0d370d624ea`. + +[A130](android-audit-2026-09.md#a130) is source-fixed at the focused checkpoint: Live-button +touch-down calls `cancelScroll` before stopping propagation, preserving history and its +display epoch until click. Three new JVM behavior methods cover held/moved touch, normal +click/obsolete page, and cancelled press/current page. Control/restored runs pass 48 methods/7 +suites with zero failures/errors/skips; three semantic mutants (old handler, premature history +close, parent gesture) fail assertions. Receipt `a130-live-touch-proof-3thll7zv/receipt.json`, +SHA-256 `7eae4fd3f280d945be7dae6544c6569cea9d931b7818dd91e78bceae01be9753`. +These are compiled JVM and shipped-page DOM/xterm/bridge-stub/RAF checks, not actual +WebView/transport/phone acceptance. Published `5bda2c32` passes fresh 1015/106 protocol, +offline app/full TypeScript and exact-head all-five/ten-step CI `37608652625`; retained-signer +beta 19/code 20 is installed. Actual app/phone behavior acceptance remains pending. + +### A128 composed Send backend follow-up (2026-10-07) + +Current local relay Send supports direct native Windows PTYs and session hosts advertising +`composed-input-v1`. The v2 internal prepare/write/cancel extension owns one-use 10-second +socket/generation-bound tickets and holds the per-terminal lock across paste and separately +guarded Enter at least 150 ms later. Subscriber registration and original socket are checked +inside the deferred client write. Lost transmitted-phase receipts/RPC errors stay uncertain, +with no replay; old live hosts refuse without restart. Busy/stale attachment failures do not +prescribe an update. The public Android action/result stays unchanged, with actual backend +producer-to-Kotlin coverage in the same change and an iOS availability note for @eneskirca. + +See [the source and verification checkpoint](android.md#composed-send-backend-follow-up-2026-10-07-a128). +At that earlier checkpoint, installed beta 17 remained `a79375c3`; native Linux component proof and recorded backend byte +fixtures do not verify Windows ConPTY or a phone using these additional backends. Keep the +remaining device/provider/backend obligations and original paused ledger separate. Before +publication, run the mandatory offline protocol/app and affected Vitest/full TypeScript gates; +after push, manually dispatch beta preparation and verify all five jobs on that exact revision. + +### Beta 17 installed; merged gates and bounded A127/A128 device cases (2026-10-06) + +Exact signed `a79375c3` passes full forced offline **982 methods / 102 suites**, zero failures, +errors or skips, offline app Kotlin, **187 affected Vitest tests / 11 files** and full +TypeScript. Android [run `37523703627`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37523703627) +passes all five jobs and ten critical steps. Retained-signer beta 17/code 18 is installed on the +intended Pixel 7a in 5.48 seconds, with exact prepared/installed APK hash `c51433d4…`; actual +SDK-36 signing/verification is used and SDK-37 tools are unavailable. + +The single saved temporary SSH profile/first-install time survives update without pin editing +or key readmission. A128 emacs copy-mode Send is native-verified exactly once on the original +producer, with copy mode cancelled and the guard unchanged. A127's viewer-only exit shows the +neutral notice, disables Send and retains the draft. Manual Reattach preserves the producer +and complete same-grid history; the retained draft submits once. Controlled vi transport +recovery preserves the draft/same pane/full capture, then explicit Send submits once and exits +copy mode. One raw Ctrl+C produces exactly `03`, without any additional byte in a bounded +0.8-second window. Actual phone-managed shell creation and exact owned SSH End pass too. +Only the temporary profile/admitted public-key line are retired; the original authorization +file is restored. The nonce-validated owned host stop passes with authentication removed, +source/tools/build outputs unchanged and both listeners closed; ordinary Quit is unverified. +The frozen physical report binds 303 raw hashes/nine bounded passes, SHA-256 +`ece4fa48d31fab55a9f5158f475b4415e9c116c8a2f1683ec48295bf42e55e6b`. See [the bounded beta-17 checkpoint](android.md#pixel-7a-beta-17-upgrade-and-composed-send-2026-10-06). +The original Pixel 10 Pro ledger remains paused at **10 Pass / 22 Partial / 32 Pending**. + +### A128 composed Send source fix and beta CI registration (2026-10-06) + +`7e7d2c04` integrates explicit awaited actor submission on the captured viewer. Unsent momentum +is cancelled; host history mode is cancelled only for that exact viewer/pane generation. +Bracketed paste precedes a separate Enter by 150 ms with a fresh guard; Ctrl is one raw byte +without Enter. One delivered same-current-view completion clears only an unchanged draft +revision and preserves newly rearmed Ctrl. Refused, uncertain and stale outcomes retain the +draft; post-paste failure is uncertain and never replays. The receipt confirms pane/PTY +submission, not command execution. Direct Unix SSH and current local-tmux relay are supported; +legacy/unverifiable, native Windows/session-host and SSH-project relay routes explicitly refuse. + +The additive `pty.submitComposed {streamId, input}` verb, Android client and actual interop fixture +land together in `7e7d2c04`; iOS **@eneskirca** needs the same action/receipt and draft-retention +rules. **170 distinct affected Kotlin methods / 8 suites**, **187 affected Vitest tests / 11 +files**, full TypeScript and forced offline app compilation pass. All **26 isolated source +mutations** fail assertions, with control/restored runs passing. `5484ab9f` registers four host +suites in private-beta CI; nine actual Gradle/JUnit configuration methods pass and four +registration deletion mutants fail assertions, with passing control/restored checks. The total is **30 assertion-caught +mutations**, not a repaired phone pass. + +The [source checkpoint](android.md#composed-send-source-checkpoint-2026-10-06-a128) binds aggregate +V2 `34a0044207b64b5627f0a8653a9969234430a51b697d8ef589220f9e9a26b68f` and the CI registration +receipt `6f5b956e6c4ff2d9c0f8a6c070ba429b1cfe8c1175852c56bf30be159c5720f9`. +Independent read-only review finds no material source/evidence issues. At that source-fix +checkpoint integrated full gates, exact-head CI, private beta packaging and repaired-device +checks were pending. The later beta-17 entry above records their bounded completion: merged +982/102/app and affected Desktop gates, five green CI jobs, the same-signer update, emacs Send +and A127 viewer-close/manual-Reattach. Later vi recovery/Send and raw Ctrl checks pass too; +exact owned End/cleanup and the final report are verified separately in the latest entry. +Wider physical/provider checks remain pending; the original 10/22/32 ledger is unchanged. + +### Pixel 7a lifecycle and custom scrolling; A127/A128 discovered (2026-10-06) + +Trusted Wireless debugging is restored and the intended Pixel 7a's beta 16 installation is +hash/signer verified. Actual Start→Back and Start→Home videos, native registration/pane proof, +background process restart, custom `-N7` emacs/vi held dragging and bounded momentum/touch-stop +checks pass against the disposable cached `59e4c93e` Desktop. Transport reconnect preserves the +pane/history and disables Send while disconnected. Offscreen Find and exact-owned SSH End pass. +These are bounded cases, not completed checklist rows. + +A127 reports a failed viewer as a dead session while the producer survives. Signed source fix +`0d50075a` changes only the fallback wording and stream-exit documentation; three JVM methods, +offline app compilation, twelve callback cases and three assertion-caught mutants pass. +A128 loses composed input in copy mode on the installed beta 16: both vi after reconnect and +healthy emacs clear the draft with no submitted command/marker. Its later `7e7d2c04` source fix +and `5484ab9f` CI registration have the separate checks recorded in the entry above. +Neither fix had an updated-device pass at that beta-16 baseline; later bounded beta-17 +repaired-device acceptance is recorded in the entry above. + +The temporary phone profile and admitted public key are removed; the exact owned fixture stops +cleanly with authentication removed/listeners closed. This was a controlled SIGTERM stop, +not ordinary Quit acceptance. The original phone and regular Desktop remain untouched. +The [physical receipt](android.md#pixel-7a-lifecycle-scroll-and-input-follow-up-2026-10-06) +records artifact hashes and limitations; the original ledger stays 10 Pass /22 Partial /32 Pending. + +### Inactive global-card owner and bounded park/adopt verified (2026-10-06) + +One fresh Linux Desktop two-host soft/none case passes at `59e4c93e` in 27.39 seconds. Ordinary +native palette project switches preserve B's same quiet xterm/core/wrappers/subscriptions/API and +native client through park/adopt while its global card stays open. With A active and unchanged, +a B-only product master outage recovers that inactive card to a fresh warm selected viewer. +Three native `g` ACKs, two complete wrapped URL activations, zero unexpected input, one paint per +role, full same-grid retained remote history and exact owned cleanup pass. SID provenance is the +primary create/Canvas `g1` and carried original writer, not an adopted Canvas React ref or a +post-adopt Canvas input test. See [the bounded two-host follow-up](android.md#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06) for the receipt/runtime hashes. +This extends acceptance of existing A125/A126 source; no repair/test/mutation count or physical +ledger is added. Phone/backend acceptance and wider platform/stress coverage remain separate. + +### A126 duplicate terminal responses found after A125 (2026-10-06) + +The historical `11e22453` stale-Modal negative and first `415dae9b` control retain their original +provenance. The latter creates replacement viewers but records 18 unexpected DA1/DA2 bytes after +ACK 1; ACK 2 is not attempted. Its unclassified wrapper/runtime exits 1/2 remain separate from the +later A126 diagnosis. Those failed controls are not rewritten as repaired-source positives. + +A125 is source-fixed in `415dae9b`; A126 is committed in `5b7286b3`. +Eight actual Linux Desktop **Canvas/Modal × soft/hard wraps × inner none/SGR mouse** cases +pass at `5b7286b3`. Each loses one birth-pinned product ControlMaster, creates a new master/ +selected local viewer and retains its remote pane/server/producer/cwd and complete same-grid history before, +during, after and at final observation. Both trusted actual textarea `g` inputs are acknowledged +on the old/new PTY: **16 ACKs**, **zero unexpected bytes**, one producer paint per case and +**16 complete URL activations**. Native hover/leave and exact owned cleanup pass. Modal cases +use `80x24`; Canvas cases use `49x48`, with each history comparison bound to its own grid/case. +**294 unique affected tests / 17 files** and full incremental TypeScript pass. **15 isolated +mutants (six A125 + nine A126)** are caught by assertions; each fix's control/restored runs pass. +Forced offline checks at `5b7286b3` execute **950 tests / 98 suites**, zero failures/errors/skips, +and both protocol/app Kotlin compile tasks. These are disposable QA hosts; no new APK or regular +Desktop installation is included. Publication requires fresh offline gates and all five Android +CI jobs for the exact published HEAD; its private bundle and final report carry that run's result. +See [audit A126](android-audit-2026-09.md#a126) for the original failure and accepted matrix. +The later two-host follow-up above adds bounded quiet park/adopt and inactive global-card acceptance. +Phone/backend obligations and wider Server/cross-window/GPU/outage/platform checks remain separate, +with the paused phone ledger unchanged. + +### Beta 12 prepared; merged release checks pass (2026-10-04) + +**Historical prepared private beta 12 (2026-10-04).** `0.1.0-beta.12` / code `13`, clean built source +`b2e41255d8b2cf7bb342b78b7f686b8b0191879e`, includes A100–A110 and the earlier beta-11 additions. Full offline protocol +checks pass **815 tests / 83 suites**, with zero failures, errors or skips. +Offline app compilation, **1314 affected Vitest tests / 53 files** and full TypeScript +checking pass. The ten existing Windows/macOS runtime cases are explicitly skipped on this Linux +host; no additional skip is accepted. The minified offline release build passes in +**52.47 seconds**, with retained private signer, R8/runtime keeps and source/hash provenance. +Independent SDK 36/37 signature, nondebuggable manifest, payload and 16-KB ZIP/native checks pass. +The private support archive retains **236 isolated mutation variants** with passing controls and +restored runs: 228 assertion cases, six named executable-contract failures and two bounded timeouts. +Historical source hashes remain attached to their own receipts; this count does not claim that +all earlier worktrees were identical to the final merged source. Full merged release gates pass. +APK SHA-256: `d5a350a1f7c6f05f1ab476f25cbe58441eb666d61d1a4b62610a3faa2e058e1f`. Artifact: +`.nodeterm/android-beta-12/nodeterm-android-0.1.0-beta.12.apk`; build and mutation proof: +`.nodeterm/android-beta-build-12/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone checks are paused, with **10 Pass / 22 Partial / 32 Pending**; +live Claude rule/question application and the new physical feature matrix remain unverified. +Each requested push still needs its own exact-head green Android workflow. No PR opened; A68 +remains deferred until a PR is requested. + +### Owned SSH actions and remaining source gaps (2026-10-04) + +A100–A109 are implemented in small signed commits. Retained-history matching, interrupted typed +read recovery, trusted project env/shell preparation, measured per-agent policy and offscreen wake +have regression and isolated mutation receipts. V2 held replies add exact remembered rule scopes +and complete question answers without guessed digits. Direct SSH Git supports the existing eight +verbs with physical jails and honest uncertain writes. The selected-profile SSH actions service +routes Board writes through the actual store; Desktop offers delivery-only node nudges. Server +serves local Board writes and, since A111, managed New when its local POSIX tmux backend is enabled. +Exclusive lifetime claims, monotonic retirement, bounded replies and immutable nonce +windows guard startup, shutdown and retries. The service's actual filesystem/FIFO, Desktop wiring +and headless Server checks pass 33 tests; 29 native and 29 client mutation variants are caught. +Real Node/Kotlin/generated-shell interop passes. The full merged release gates follow this source +checkpoint; no new device or live-Claude application result is claimed. + +Current commits: A100 `2f693d83`/`97b8a9a4`/`4f1985f5`; A101 `fbaa535d`; A102 `e584586f`; +A103 `91e6a3f5`; A104 `2d6cb2cd`; A105/A106 `db4abccf`/`7c206ec5`; A107 `489c30a8`; +A108 `d716138c`/`585e726f`; A109 `4e2f877d`. The original A56 blind-digit proposal is superseded, +giving 74 of the original 77 source fixes. Historical records below retain their original scope. + +The user intends an upstream PR contribution and cannot supply the backend repository. A25 FCM +and A93 fresh-different-desktop relay recovery remain maintainer dependencies. iOS implications +for @eneskirca include v2 held mirror/verbs/SSH answers, retained history, launch/policy facts and +the optional selected-profile SSH actions file contract. No PR opened; A68 remains deferred. + +### Beta 10 installed: saved relay pairing survives update (2026-10-04) + +**Current private beta and paired update (2026-10-04):** `0.1.0-beta.10` / code `11`, +clean snapshot `e3ce041c752bdef11b378cf301c719a6e62f70ab`, is installed on the intended Pixel with the +unchanged private signer. The actual release build passes in 52.4 seconds with all R8 keeps; +independent SDK 36/37 signature, 16-KB alignment, payload and provenance review passes. +APK SHA-256: `04ace0ea01540bb477fcf823395a299fa211cfef3745aa28674099038ceb3693`. The 10.14-second +same-signer update preserves install identity, notification grant and the owned SSH second +pane. On beta 9, genuine desktop-issued code text re-pairs the complete original owned desktop +profile and receives relay credentials; safely anchored external SSH refuses, then Automatic +falls back to the actual relay. After updating, that saved +pairing reconnects without re-pairing; the own Terminal1 opens and input reaches its real scoped +PTY. **Items 1/36/51 pass; items 35/49 remain Partial, giving current 10 Pass / 22 Partial / 32 Pending.** Debug-key update/migration +variants remain conditional SKIP on this working private installation. The same owned production +desktop profile now runs repaired build `ec12ea9a`; the original failure was on `071735d6`. Local gates pass 689 protocol tests /67 suites with zero +failures/errors/skips (86.08 seconds wall time including a new daemon), offline app compile +(6.78 seconds), 54 affected Vitest tests and full TypeScript checking. The exact built source `e3ce041c` has all five CI jobs green in [run `37194375778`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37194375778). Each later documentation push needs its own green workflow. Private artifact/proof: +`.nodeterm/android-beta-10/` and `.nodeterm/android-beta-build-10/`; the canonical paired-update receipt is `.nodeterm/android-beta-build-10/paired-update-result.json`. Broader relay/managed, +cellular and the full device checklist remain open; installed A91/A94 full focused empty-host flow passes. + +**Installed A91/A94 focused empty-host cycle passes (2026-10-04).** On beta10/code11, +native End of the otherwise-empty fixture's sole owned Home shell removes its row/group and +shows “No sessions on this computer yet.” with no Loading label; Over SSH and the data-not-found +banner remain. New terminal → Home → Create opens a new actual bash pane in Home. Back to +Sessions and exact native End removes that new UUID and restores the same completed empty +screen. All three isolated host sockets are empty. Proof: +`.nodeterm/android-beta-build-9/checklist-20261004/beta10-a94-full-cycle-result.json`, +`beta10-a94-recreated-pane.json` and the matching empty-screen UI receipts. This passes the +focused A91/A94 empty-host flow; item 32 remains Partial because managed and cellular +creation remain open; the relay plain-shell variant passes. Total 10 Pass / 22 Partial / 32 Pending. Final fixture/service cleanup is +not yet claimed. + +**Relay creation, Rename and exact End verified; Refresh dispatch observed (2026-10-04).** On +beta 10, Sessions → New session → Terminal (shell) → Start creates and opens one actual +canvas-registered shell in the exact private project folder. Native input and pwd reach its +scoped bash pane. Rename updates the mounted desktop title without reload. Sessions Refresh +rediscovers both owned rows; the actual owned session-menu Refresh emits exactly one node-refresh +IPC, preserving pane identity and page time origin. Its repaint effect remains unverified. +Exact native End then removes only the adopted node/pane/PID birth; the original producer +terminal remains unchanged. The mounted Board also removes that card without reload. +Item 32's relay plain-shell variant passes; managed-account/cellular and other required variants +remain pending. Item 35 stays Partial for missing Wake and Refresh repaint evidence; item 36 +passes actual repaired-desktop move/remove/re-add/create-label checks (A95). Earlier first-round rotation restoration is historical; final restoration remains pending. The tally is 10 Pass / 22 Partial / 32 Pending; no full release/device pass +or final fixture/service cleanup is claimed. + +### Phone Board move persists but mounted desktop stays stale (`A95`, 2026-10-04) + +**A95 is fixed and verified on the rebuilt production desktop (2026-10-04).** The held +`071735d6` build reproduced stale mounted Board moves/labels. The same owned paired profile now +runs the repaired `ec12ea9a` production build; Android remains installed beta10/code11 (`e3ce041c`), +with no APK change for this desktop fix. Actual phone Move to Ungrouped, label removal, re-addition +and creation/application of a second blue label update both persisted state and the mounted +desktop Board without a reload. Baseline and every resulting DOM snapshot share page time origin +`1791111312961.1` and match persisted state. This completes item 36. Nine affected Vitest suites +(133 tests), full and strict acceptance TypeScript checks, and four isolated mutants also pass. +The ledger is 10 Pass / 22 Partial / 32 Pending. This verifies the owned production fixture; +no deployment to the user's regular desktop or full release/device pass is claimed. + +### Pixel checks resumed: relay mint refusal and completed-empty loading label (2026-10-04) + +Genuine production pairing JSON was entered through the native Pair screen without reading or +changing either clipboard. This verifies code-text entry and parsing, not scanner or clipboard +mechanics. Pairing to a fresh isolated desktop with remote access on succeeds locally, but no +relay credential arrives. A bounded retry using the exact production mint request body confirms HTTP 403 reauth_required +(A93). The Pixel keeps its global identity; forgetting the prior fixture removed its saved +relay token, and the fresh desktop identity differs. Backend C2 policy is not proven by that +refusal. The complete original owned profile is under assessment for ordinary encrypted-keyring +restart and re-pairing; no identity/token fields or user credentials are transplanted. + +The separate otherwise-empty OpenSSH fixture shows a completed error banner, Over SSH and +New terminal while “Loading sessions…” persists on installed beta 9. Source tracing identifies +the initial empty snapshot's zero fetch time (A94). Commit 3c217cba stamps authoritative empty +answers; five actual Kotlin regression methods and eight isolated mutants pass. Full Gradle +checks, a new APK and installed-fix verification remain pending. Before-fix proof is in +.nodeterm/android-beta-build-9/checklist-20261004/a91-initial-empty.json and .png. +The subsequent focused A91 flow passes on installed beta 9: New terminal creates/opens a first +Home shell, native End removes its exact row and Phone terminals group, SSH stays connected, +and New terminal creates/opens a second Home shell. That second shell is retained for the later +higher-code update check; no final cleanup is claimed. Proof: +.nodeterm/android-beta-build-9/checklist-20261004/a91-beta9-focused-results.json. +A94's label correction still awaits delivery. No full checklist promotion is claimed; the tally +remains 7 Pass /21 Partial /36 Pending. + +### Desktop wrapped-link follow-up fixed in `127b6b28` (`A92`, 2026-10-04) + +Re-read the recorded desktop gap and reproduced the actual exported `paragraphContaining` +behavior: below 32 continuing rows its downward window could omit the hovered row. Reserving +one row in the upward budget fixes containment and complete URL matching while preserving the +32-row cap. Three meaningful regressions cover all 80 requested hard/soft rows and the actual +URL provider's tail-row text/range/activation. Focused Vitest 24/24, all three affected link/dialect +files 54/54 and full `npm run typecheck` pass. Three isolated mutants are caught; control/restored +copies pass. Evidence is `/tmp/nodeterm-file-link-fix-verify/results.json` and per-variant logs. +Fixed in `127b6b28`; interactive desktop hover was pending at this original checkpoint. The later +Oct5 receipt verifies eight native Linux Desktop cases at `01b4a2f5` with A121's leave fix. The held isolated fixture retains +the unchanged `071735d6` desktop build; `out/` was not rebuilt. Android already has the `A32` +correction, so no Android/client contract or iOS adoption is owed. Installed beta 9/code 10 and +the 7 Pass / 21 Partial / 36 Pending ledger remain unchanged; no new phone evidence is recorded. + +Newest first. Each entry says what landed, how it was checked, and where the fix differs from the +audit's proposal. + +### Focused A90 Pixel flow passes; item 32 Partial (2026-10-03) + +On beta 8/code 9 (`b88d1415`), Home, discovered project and custom-folder creation verify cwd/input +and 180 numbered history rows. Custom path spaces/apostrophe/dollar, rapid double Create without +duplicates, visible missing-folder refusal/no orphan and corrected retry pass. Same-pane reopen +shows pre-attach history; drag produces 14 positions, coast moves 65→137 and Esc exits copy mode. +New shells carry no managed-agent environment and retain 50000 history lines. Force-stop/restart +keeps three PIDs/history and SSH rediscovery. Beta9/code10 (`4d33a5b5`) update/reconnect/input to +the same pane pass. Native UI End of custom→project→Home removes only the chosen UUID, preserving +siblings' immutable fingerprints/PIDs, with counts 3→2→1→0 and final Phone terminals group removal. +All 11 desktop session IDs/pane PIDs and 9 protected project/workspace hashes are unchanged. +Owned shells, empty fixture folders and remote UI dumps are removed; Pixel is left on regular +host Sessions. No live managed agent/account or shared-file writes are involved. + +Proof: `.nodeterm/android-beta-build-8/a90-pixel-check-20261003/final-focused-results.json` and +`beta9-exact-ui-end.json`. Only item 32 moves Pending→Partial; current tally 7 Pass / 21 Partial / 36 Pending. +Relay canvas/managed and cellular creation, A91 otherwise-empty-host last-End and the broader +release checklist remain unverified. Broader device work stays deferred. + +### Beta 9 installed; empty-host fix delivered and owned shells retained (2026-10-03) + +Private `0.1.0-beta.9` / code `10` uses clean source +`4d33a5b5366c99479b648086649205350c7752b1`. The actual release built in 43 seconds and the +same-signer update took 6.46 seconds on the exact intended Pixel. The pulled installed APK matches +SHA-256 `719cfeea1dcf27900dd35692a59004ca07e8261b3f14bd43922f0706b6b4ab54`. +Three owned shells created on beta 8 survived and were rediscovered on beta 9, then were ended +through verified exact-session cleanup; none remain. `A91` is delivered; +its otherwise-empty-host physical variant remains pending. Broader device checks stay deferred. +Independent SDK 36/37 artifact review verifies signatures/16-KB alignment, all 149 unsigned +payloads preserved after signing, four ELF alignments, expected R8/service metadata and provenance. +The non-debuggable update preserves install identity, notification grant and app data, without +uninstall/clear storage. Paired relay credential survival remains unverified. Proof: +`.nodeterm/android-beta-build-9/artifact-review.json` and +`.nodeterm/android-beta-build-9/device-install-20261003/receipt.json`; artifact: +`.nodeterm/android-beta-9/nodeterm-android-0.1.0-beta.9.apk`. + +Historical source CI run `37144282865` failed `DeviceChecklistDocsTest`: `A91` lacked a checklist +mapping, and unrelated Known gaps bullets shared one paragraph. This revision adds the item 32 +mapping and isolates the paragraph. Debug/release APK and +CodeQL jobs passed; private-beta packaging was skipped. The recorded prior all-green branch was +`19da35a2` / run `37140762345`; the next push needs its own required checks and green workflow. +The recorded local 688/67 source baseline predates these final docs; fresh full protocol/offline +app gates follow the frozen documentation. Final exact-head CI proof is retained separately. + +### Historical empty-host stale listing host fix, before beta-9 delivery (`A91`, 2026-10-03) + +Ending the last phone-owned shell on an otherwise empty SSH host yields `NothingFoundException`, +but `ConnectionManager.refreshNow` previously retained the cached row. The pure +`ListingFailure.snapshot` policy clears only that authoritative empty answer. Host refusals and +transport errors retain the previous snapshot; cancellation propagates before replacement. +Native wiring preserves the route-specific error and the existing connection rules, including +connected SSH/New terminal on an empty host. No RPC, projects blob, pairing, mirror or SSH-visible +file contract changes. + +`ListingFailureTest` adds four methods for an actual parsed phone-terminal listing, generic-error +retention, cancellation identity and native error/connection wiring. The real full Gradle run +passes 688 tests in 67 suites, zero failures/errors/skips, in 52 seconds; offline app compile +passes in 6 seconds. All six isolated Kotlin 2.2/JDK 21 mutants are caught; ignored proof is in +`.nodeterm/android-beta-build-9/`. Independent review finds no blocker. At this checkpoint beta 9 +was planned, not prepared or installed; beta 8/code 9 was bound to `b88d1415`. The previous +green branch is `19da35a2`, all five jobs in run `37140762345`; the fix's next push needs its own +gates and green workflow. Broader phone work remains deferred except explicitly authorized A90 +checks. Incomplete new physical QA is not recorded here; the 7 Pass / 20 Partial / 37 Pending +ledger remains unchanged. + +### Beta 8 installed on the intended Pixel with app data retained (2026-10-03) + +The user supplied a new wireless-debugging address and authorized the update. The exact intended +Pixel 10 Pro / Android 17 was confirmed before a 28.05-second `adb install -r` of beta 8 / code 9. +The pulled pre-update APK matches known beta 6 / code 7; SDK 37 confirms the retained public +signer. The pulled post-update APK matches beta 8 SHA-256 +`d373ad5c1790f714cb4464ad4a0a38c5ba9ab68e35103e54cf3aef5ce53081ce`, source +`b88d141528c1051964da07faf22cc7fa923c4846`. Package metadata confirms code 9/name beta 8 and +non-debuggable; `firstInstallTime` and granted `POST_NOTIFICATIONS` are preserved. The app starts +and the existing manual-SSH computer is still in All computers. Opening that exact host reconnects +over SSH and loads its real driven projects. The screenshot shows New terminal and own-app UI XML +confirms its FAB is enabled/clickable, without a TalkBack claim. No existing pane was touched and +no terminal was created or ended. Host configuration/reconnect/browse survives; item 32's actual +Create/cwd/history/reconnect/End and the 7 Pass / 20 Partial / 37 Pending ledger remain unchanged. +Broader device checks stay after the hike. + +Private proof is `.nodeterm/android-beta-build-8/device-install-20261003/receipt.json`, with +package before/after, APK pulls/public certificate checks, `sessions.png` and `ui-sessions-ready.xml`. Recorded prior branch +`19da35a2` has all five CI jobs green in run `37140762345`; the APK remains bound to `b88d1415`. +The next documentation push needs its own checks/green workflow. For item 1, pair on current +beta 8 using desktop-issued JSON or QR and verify those pairing/relay credentials across a later +same-signer higher-code update, without downgrading or uninstalling the working app. + +### Plain terminal creation for manual SSH/WireGuard implemented; beta 8 prepared (`A90`, 2026-10-03) + +The user cannot start a new shell from their manual SSH host and authorizes adding it. The host +contains `nodeterm-rmt` sessions and SSH-visible project files from another desktop, with no own +workspace or relay to register into. The bounded solution is explicit plain-shell creation on +`nodeterm-phone`, chosen discovered cwd or Home, with session creation marker/metadata and a +synthetic Phone terminals group. Shells survive disconnect and End targets only the owned session. +No shared project.json, canvas registration, agent hooks/account identity or current RPC/blob/ +pairing/mirror/SSH-visible file contract changes. Desktop and Server Edition retain their socket +ownership; @eneskirca can carry the isolated socket and marker/metadata design to iOS. + +Protocol commit `bcc92367` and UI/model commit `b88d141528c1051964da07faf22cc7fa923c4846` implement +the path. Creation records ID/cwd/request/fingerprint atomically, so a process that dies before +option finalization leaves a rediscoverable shell. Validated fingerprints guard live attach, +typing and End; reserved IDs cannot create relay phantoms. Same-ID/folder retry retains the shell +even after a folder rename. The new pane clears stale managed identities and normalizes a warm +server's non-UTF-8 overrides. Host-owned jobs survive dialog dismissal/background; Main navigation +rechecks the current screen/ticket. Full protocol tests pass 684/66 with zero failures/errors/skips +in 48 seconds; offline app compile passes in 1 second. All 32 new mutations are caught: eleven +actual Gradle/Kotlin 2.2 protocol behavioral, twelve helper behavior and nine native wiring variants. + +At this preparation checkpoint private `0.1.0-beta.8` / code `9` was **not installed**, from clean `b88d1415`. +The actual offline AGP release built in 49 seconds; R8 keeps and retained-signer packaging pass. +Independent SDK 36/37 tools verify v2/v3 signatures, one retained signer and 16-KB alignment; +all 149 unsigned payloads are byte-preserved (three signing entries added), four ELF PT_LOAD +alignments pass, and R8/service mapping and source/version/hash/build inputs agree. Terminal +assets/native libraries remain byte-identical to beta 6. APK SHA-256: +`d373ad5c1790f714cb4464ad4a0a38c5ba9ab68e35103e54cf3aef5ce53081ce`. +Private artifact: `.nodeterm/android-beta-8/nodeterm-android-0.1.0-beta.8.apk`; proof: +`.nodeterm/android-beta-build-8/`, including `artifact-review.json`. + +At that point beta 6 / code 7 was installed and beta 7 / code 8 was prepared, unused; the seven +Pass / 20 Partial / 37 Pending ledger was unchanged. The later installation receipt above supersedes +the planned beta-6→8 update; item 1 now requires pairing on current beta 9 before a later higher-code +update. Use Sessions → New terminal → Home/project/custom absolute +folder → Create; verify real cwd/input/history, disconnect/app-restart rediscovery and exact owned +End under item 32, preserving other sessions and desktop files. At that checkpoint physical proof remained pending. +This separate feature keeps `A08` and `A26`'s canvas/relay decisions intact. + +### Historical same-signer beta-7 update prepared, unused (2026-10-03) + +Private `0.1.0-beta.7` / code `8` is prepared, **not installed**, from +`b53610deb3843b59fa6a1bed5bdc5f36da0f5146`. The local AGP release built in 47 seconds; +R8 and retained-signer packaging passed. APK SHA-256: +`5141c6484b422b236a98213731076be621c4d14a55f47c79bfd989fb23609e6a`. +Private proof is in `.nodeterm/android-beta-build-7/`, artifacts in `.nodeterm/android-beta-7/`. +At that point installed beta 6 / code 7 remained at `c4b1f6cf`; its physical evidence and the seven Pass / +20 Partial / 37 Pending tally are unchanged. No new runtime fix, finding or phone work was added. + +At that stage code 8 was the planned paired-update candidate; it remains uninstalled and is now +superseded by beta 8 / code 9, later replaced by installed beta 9 / code 10. Debug migration remains a +conditional SKIP on the working Pixel. Recorded historical CI baseline `b53610de` has all five +jobs green in [run `37073041994`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37073041994); +the next documentation push requires its own checks and green workflow. + +### Requirement audit corrects item 18; remaining device work deferred (2026-10-03) + +A read-only review of all 64 requirements promotes item 18 to Pass on existing +`physical-chips-interior.json`, `keyboard-results.json` and `recovery-results.json`: all 17 sending +chips, four application-cursor arrows, real software input, A−/A+ and rotation preserve the SSH +connection. Its former extra route/geometry requirement belongs to other items; viewport/Fit +remains Partial under item 23. Item 1 stays Partial and requires a desktop-issued pairing and +relay credentials to survive a higher-code update; actual JSON or QR pairing is acceptable. +At this checkpoint the tally was seven Pass / 20 Partial / 37 Pending, with no other promoted row. + +The user defers remaining Pixel release checks until after the hike. No new phone/service work, +runtime fix, test change or product finding accompanied this audit. Afterwards, finish the +remaining 64-item QR/cellular-relay/SAS/worker/outage and paired-update gates, then the remaining +scroll/viewport matrix. Full release readiness, hosted cellular relay and live-Claude verification +remain open. Existing APK/source/hash and user-confirmed cellular WireGuard SSH connection/smooth +scrolling are unchanged. Recorded historical CI baseline `92ab112df0e2b2bad1b77a1adf6b55dfa7c552a8` +has all five jobs green in [run `37071299440`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37071299440); +the next documentation push needs its own required checks and green workflow. + +### Final cellular WireGuard check confirmed by the user (2026-10-03) + +With WireGuard enabled and Wi-Fi off, the user confirms beta 6 / code 7 "Connects and scrolls +smoothly" in their usual manual-SSH terminal, confirming connection and smooth scrolling. This verifies the +regular cellular VPN route; hosted cellular relay remains untested. Item 20 stays Partial and +as of this checkpoint the table stood at six Pass / 21 Partial / 37 Pending. QR/scanner, cellular relay/SAS, worker/ +notifications, actual outage and the rest of the 64-item matrix remain the next gates. No runtime +fix or phone command accompanied this check. Latest required protocol/offline app tasks pass in +50/1 seconds; the preceding 54/5-second QA checks are historical. Recorded green CI baseline stays `58a202be`; +each subsequent documentation push still needs required checks and its own green workflow. + +### Mounted relay questions and scoped cleanup verified (2026-10-03) + +On the desktop-mounted owned terminal, tapping single-select Green delivers actual application +option 2, and shipped PostToolUse resolves the desktop mirror question. The multi-select card +has read-only options, "Choose several — answer in session" and Open session; opening the owned +terminal and sending 2 plus Enter from the phone input bar reaches the synthetic application and +its actual PostToolUse. For a separate single-select case, the exact owned tmux pane is in copy +mode before PreToolUse. Tapping Blue delivers option 3; copy mode is false afterward, and actual +PostToolUse leaves the mirror working with its own question resolved. These are production hooks +and the actual mounted transport path, with a synthetic application and no live Claude account. +Item 41 remains Partial: offscreen/released/direct-SSH and missing/prefix-collision target device +variants are not verified; regression tests cover transport/target guards. Private desktop +`hook-qa-*.jsonl` and phone `relay-single-before/after`, `relay-multi-refreshed/multi-terminal/multi-input`, +`relay-copy-question-before/after` proof record the outcomes. At this checkpoint the 64-row table had six Pass, +twenty-one Partial and 37 Pending, with conditional SKIP variants. + +Cleanup forgot only the disposable fixture host, returned the phone to its regular Sessions, +removed only the owned SSH fixture session, and stopped only the owned isolated desktop/CDP. +Fixture-device revoke/stop/remote-access-off cleanup left zero fixture devices; this is not a full +device revocation or backend assertion. The final user-confirmed beta-6 cellular WireGuard SSH +check is recorded above. APK/source/hash are unchanged; recorded CI baseline `58a202be` has all +five jobs green. The forthcoming documentation push needs its own checks and CI verification. + +### Real relay terminal and shipped-hook approval lifecycle verified (2026-10-02) + +The actual Pixel attaches through the hosted relay to an owned plain terminal and sends harmless +echo input to its real PTY (`relay-input-capture.json`: `hasExpectedEcho: true`; phone UI: +`relay-terminal.json`). The owned project/node was seeded through the production preload and +Canvas event, not phone New session or its folder picker. The production desktop is source +`58a202be` in the private home; the installed APK remains source `c4b1f6cf`, code 7. + +A synthetic application invokes the desktop's shipped managed Claude hook for the owned node, +with its authenticated private-home endpoint. Pixel Inbox Approve returns actual allow JSON in +17.596 seconds; Deny returns actual deny JSON in 6.571 seconds. A 45-second expiry returns empty +output; a subsequent Approve visibly says "The request timed out on computer. Answer it in +session." and opens the owned terminal without false success. Item 39 passes this held-hook +lifecycle with an explicit producer limit: no live Claude CLI/account or requested Bash execution +was involved. Mounted question/copy-mode proof is recorded in the newer entry above; remaining +routes/target variants and notification actions remain unverified. +Private proof is in `hook-qa-*.jsonl` under the isolated desktop fixture's proof directory. + +The latest required checks pass 658 protocol tests in 63 suites with zero failures/errors/skips +(54 seconds), plus offline app `compileKotlin` (5 seconds). At the question checkpoint, the 64-row table had six Pass, +twenty-one Partial and 37 Pending after the question follow-up above; original APK/hash/source +and the recorded green CI baseline are unchanged. +No production or interop contract changed and no new audit finding is added. + +### Live hosted relay and encrypted paste pairing verified (2026-10-02) + +The actual minified Pixel beta 6 pairs from the production PairingService's JSON through its +encrypted exchange, with remote access on, adding the fixture host alongside the retained manual +SSH host. Settings **Only through the relay** then opens the isolated empty Sessions workspace, +whose header says **"Through the relay · end-to-end encrypted"**. This proves the current +`{deviceToken, hostId}` request to hosted `POST /v1/relay/join` and its accepted response with the +real `wss://relay.nodeterm.dev` transport, beyond the interop fixture. The desktop is production +source `58a202be` in a private bwrap home overlay with actual DBus SecretService encrypted +credential storage; no user-profile credentials were copied. An initial Automatic SSH browse +listed the real sshd's files without opening a real node; relay-only was selected before any node +action. Remote access was enabled at pairing and the phone is already approved, so no SAS prompt +is expected for this path. QR/scanner, cellular relay and SAS denial/revoke remain unverified; +the newer entry above adds relay terminal/input and held-hook proof. Private phone UI proof: +`checklist-20261002/relay-first-connect.json`. + +Checklist 7/8/12/16 gain partial evidence; this pairing step did not add a complete pass. The current +64-row record had six Pass, twenty-one Partial and 37 Pending at the question follow-up checkpoint, with +named conditional SKIP variants. +The APK source/hash, code 7 and recorded green CI baseline `58a202be` are unchanged. No production +or interop contract changed and no new audit finding is added. + +### Real Pixel terminal/clipboard/lifecycle checklist follow-up (2026-10-02) + +The unchanged signed minified beta 6 / code 7, source `c4b1f6cf1009f293a658b6331d2ed1ab80aa36c6`, +passes complete device items **19, 21, 22, 24 and 38**. The Pixel 10 Pro runs Android 17 / API 37 +and Vanadium `154.0.8037.92.0`; its real SSH-driven Linux host is Fedora 44/kernel +`7.2.7-200.fc44` x86_64, OpenSSH `10.2p1-14`, tmux `3.7c`. The running desktop nodeterm version +is not recorded. All content is synthetic and scoped to owned test panes. Private proof is in +`.nodeterm/android-beta-build-6/checklist-20261002/`; [android.md](android.md#what-is-verified-and-how) +has the detailed evidence and the 64-row result table. After the live relay/hook/question follow-ups, +that checkpoint had six Pass, twenty-one Partial and 37 Pending, before the requirement audit above. + +Rounded borders/accents/CJK/emoji render. Exactly 90000 clipboard characters copy; 150000 and +450000 show the app's size refusal, preserve the clipboard and do not crash. Invalid +base64/separator/query/selection/UTF8 are silent and preserve the known synthetic clipboard. +Phone-visible refusal does not itself prove the pre-bridge boundary, which remains distinct +bounded JVM/JS evidence. The keyboard chip passes all three focus states: real software `a` +and Enter reach the pane, not the native draft. Background `am kill` restores Inbox tab and +host back stack; force-stop/reopen retains manual SSH authentication without a key prompt. + +At that checkpoint, partial checks included all 17 sending chips and four app-mode arrows, A+ 56×48→52×45 and A− +restoration, rotation/draft survival, wrapped HTTP/HTTPS links from first/middle/last rows, +accepted HTTP(S) OSC8 and rejected file/JavaScript. Copy sheet copies three exact Unicode lines +(lower box border plus synthetic A/B); Share preserves selection and overlay mouse events remain +zero. Browser Open/offer-interaction and Copy-sheet links/Open remain pending. Landscape with +IME open was 129×1 and portrait returned to 56×25; usable landscape height and larger-client Fit +remain untested. Real SSH background detach/foreground reattach and owned-client detach +recovery preserve the draft; armed Ctrl+c is exact with no Enter. Airplane/outage and disabled +states, QR/cellular-relay/notifications, custom bindings/FPS and remaining checklist variants +stay open. Driver coordinates/side-Back/Compose class assumptions are helper issues, not findings. + +No APK, production or interop contract changed. Latest pushed +`58a202be84a6e07e0684584f978bb6346b3c3b52` passed all five jobs in +[run `37062631975`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37062631975). + +### Beta-5 continuous swipe failure; stable touch target corrected (2026-10-02) + +The user reports that continuous swiping stops and requires lifting before another move. On the +intended Pixel, a controlled beta-5 gesture updates history from 0 to 15 at 369 ms, 25 at 391 ms, +and 30 at 412 ms, then remains unchanged through 1.4 seconds after the ADB swipe command completes +at 561 ms. Command completion is not a measured physical touchend timestamp. This does not verify coast. `A89` records the concrete root cause: the DOM renderer replaces painted row spans, +removing the original touch target while touchmove/touchend still belong to it. Those events no +longer bubble to the live terminal handlers. The old tests dispatched directly on the host and +missed the real target's lifetime. + +`c4b1f6cf` gives `.xterm-screen` `touch-action: none` and disables pointer events on `.xterm-rows` +and its descendants. Hit testing then chooses the stable screen behind the changing text. The +real xterm interaction regression confirms a redraw detaches the old span and loses later events; +with the page's computed CSS, the screen stays connected and receives continued moves and the +release. The existing velocity fling, ordered 20-notch calls and native stop barriers remain. +No host/payload/SSH-file contract changed; no iOS fixture change is owed. + +All 658 protocol tests in 63 suites pass with zero failures/errors/skips (67 seconds); offline +app `compileKotlin` passes (8 seconds). The real-bundle redraw case delivers 24 notches with the +stable hit target versus one after detaching the original span. Thirty-five JS, eleven new native +and three CSS mutations are caught (49 total). A real Electron/Chromium probe also confirms +`elementFromPoint` hits the connected screen across redraws; original CSS hits a detached span. +This is desktop Chromium evidence, not Pixel touch/coast proof. Its log is retained in the private +beta-6 evidence. The probe used Electron 42.11.3 / Chromium 148.0.7778.280. + +Private `0.1.0-beta.6` / code `7` uses source `c4b1f6cf1009f293a658b6331d2ed1ab80aa36c6`. +The actual AGP release built in 43 seconds and passed every R8 keep. Retained-signer packaging +verified non-debuggable metadata, signature, 16-KB alignment and source/hash provenance; all 149 +ZIP payloads stayed unchanged, with only three signature metadata entries added. APK SHA-256: +`4947133a6ccf9c2b1e775e76d7c24f564e087cf036e59eac4dca08162a076d3c`. +The intended Pixel received a same-signer update preserving app data; notification permission is confirmed. Private +inputs/proof, full XML and mutation logs are in `.nodeterm/android-beta-build-6/`; signed artifacts +are in `.nodeterm/android-beta-6/`. + +**Controlled Pixel proof:** SSH reopened with the retained key/pin in the owned 56×48 pane. A +1000-native-pixel/1200-ms swipe produced 23 observed history-position updates, from 0 to 110 over +about 1103 ms; its ADB command completed at 1535 ms. A 1000-pixel/200-ms swipe reached position +110 when its ADB command completed at 540.5 ms, then 250 at 1339 ms (about 799 ms later), with +29 observed updates overall. This verifies continued drag delivery and post-command coast. +Esc during coast left copy mode (`mode=0`) and remained out for 1.4 seconds. + +New-touch stopping also passes with the keyboard already open and all sampled viewports at +56×25. After a 500-native-pixel/100-ms swipe, the DOWN command completed 152 ms after the swipe +command; the script then waited 1.2 seconds before issuing CANCEL. The last position change was at 503.6 ms, before DOWN completed at +588.8 ms; final position 100 remained unchanged through CANCEL. Earlier tap/DOWN checks opened +the IME and rebased tmux positions, so those results were inconclusive, not additional failures. +Evidence includes `beta6-stable-viewport-touch-stop.json` and `stable-touch-check.log` in the +private beta-6 proof. After these checks, Esc left copy mode, Header Back returned to Sessions +and detached the owned client. Only the exact owned `nt-term-000android-scroll-20261002-c` +session was killed; Sessions was refreshed and owned phone UI XML removed. The private +`device-summary.json` records completion. + +**User confirmation:** after normal terminal use, the user answered “Both work now” for continuous +dragging and coast. This resolves the primary drag/coast complaint under `A86`; reversal/lifecycle +checks, custom wheel bindings, FPS and the full 64-item pass remain open. The user's final beta-6 +cellular WireGuard SSH connection and smooth scrolling check is confirmed; hosted cellular relay is untested. +`A88` (`f5fd3821`) passes 39 tests per real SDK 36/37 and ten new tool mutations; CI4 failed +private packaging. [Run `37061593216`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37061593216) +at `c37798b6495b4b68df379d0ae80887c23104b66d` completed with all five jobs green, confirming the +observed packaging workflow repair. Any subsequent push still needs its own green workflow. + +### Historical bounded kinetic implementation; private beta 5 installed (2026-10-02) + +The user reports more delivered movement on beta 4 but no web-page-like momentum after finger +lift. This continues `A86`; it is not a new finding. `1ad2e944` samples the last 120 ms of touch +motion and permits a fling only within 80 ms of the last movement, at 0.45–3 CSS pixels/ms. +Animation frames integrate exponential decay with a 240-ms constant, stopping at 0.06 pixels/ms +or the hard 1000-ms/1200-pixel bounds. Calls still carry at most 20 notches; a gap over 250 ms +cancels movement. New touch/input/reset/font/lifecycle barriers clear it and use `onScrollStop` +(`53462f96`) to discard unsent native scrolls without typing into the pane. Accepted keys/replies +and the single in-flight operation are preserved. Bytes already handed to SSH's separate writer +or the network cannot be recalled. Automatic xterm reports retain the nonbarrier route fixed by +`A87`. No host verb, payload, mirror or SSH-file contract changed; no iOS fixture change is owed. + +All 658 protocol tests in 63 suites passed with zero failures, errors or skips (59 seconds); +offline app `compileKotlin` passed (10 seconds). The strengthened new-touch/reverse-drag regression +passed all seven focused kinetic tests (8 seconds, `05cc6644`). The real xterm bundle exercises coast while +focus/query reports arrive. Thirty-five JS mutations (22 existing routing/batching plus 13 kinetic) +and eleven new native helper/wiring mutations were caught. Native stop preserves accepted input, +reply FIFO and in-flight budget while refusing obsolete/retired viewers. + +Private `0.1.0-beta.5` / code `6` uses source `1ad2e94455a7adfb85d41212b12d36df39695324`. +The actual AGP release built in 47 seconds and passed every R8 keep. Retained-signer packaging +verified non-debuggable metadata, signature, 16-KB alignment and source/hash provenance. APK +SHA-256: `7cc68d384aeb21ab40800fa7c83f006dfbfefba16dd2e945967c8c5376f17655`. +The intended Pixel updated in place; installed code-6/non-debuggable metadata and notification +permission are confirmed. Private inputs/proof are in `.nodeterm/android-beta-build-5/` and the +APK/checksum/metadata in `.nodeterm/android-beta-5/`. + +**Superseding phone failure:** continuous movement stops, with no demonstrated coast afterward; +the user must lift before moving again. The newer `A89` entry above records the real DOM-target +lifetime cause and beta-6 correction. These beta-5 build/tests establish implementation, not +successful momentum on the intended phone. + +**Packaging CI follow-up (`A88`):** CI run `37058184031` at `9ced6781` passed protocol, debug, +release and CodeQL, but private packaging failed five of 32 tests at the same signer/v2 gate. +Official SDK 37 reproduces the failure with verification exit 0, v2 true and one certificate, +reported as `V3.0 Signer:`. `f5fd3821` accepts exact V2/V3.0 single labels and V3.0/V3.1 +SDK ranges, keeps the pinned-certificate/v2 policy, and rejects extra/unknown/hybrid certificates. +Fixtures now use the explicitly installed SDK 36/platform 35 unless a reproduction override is +set; signature-gate failures include only disposable-fixture verifier diagnostics. All 39 Python +tests pass against each real SDK 36/37; six parser and four fixture-selection mutants are caught. +A real SDK-37 acceptance mutation also fails if the new single-label support is removed. The +follow-up run `37061593216` completed all five jobs green and confirms observed CI repair; +the original failed CI log did not capture raw tool output. + +### Beta-3 user failure; report routing and gesture gain corrected (2026-10-02) + +The user reports that beta 3 still has both lag and too little movement, on Wi-Fi and mobile-data +VPN. The historical controlled swipes below verify their measured distance/order, not satisfactory +feel. `3cffb49d` restores one measured row per notch and fixes `A87`: xterm's automatic mouse, focus +and parser replies use a nonbarrier native report path, while real keys, paste and IME input still +cancel unsent movement. The pinned xterm 5.5 bundle is exercised in real interaction regressions. +Ordered frame batching, lossless 20-notch chunks, the serial actor, lifecycle retirement and SSH +TCP_NODELAY remain. No external verb, payload, mirror or SSH-file contract changed; iOS needs no +payload fixture change. Recheck the internal input-origin adapter when upgrading the pinned xterm. + +The controlled beta-3 phone trace records exactly one bridge invocation in each of 12 gesture intervals +and roughly one content presentation per gesture. First invocation is 84–90 ms after touchstart; +first presentation 170–196 ms is temporal correlation, not an SSH/action flow. Presented Chromium +scroll events are 36–56 ms; the >1-second aggregate mostly counts no-paint termination. Sparse FrameTimeline +and non-damaging jank records establish no terminal FPS or JIT/renderer cause. Named JS/native +methods and scheduler/V8 measurements are absent; newer extension fields have parser warnings. + +All 646 protocol tests in 62 suites pass with zero failures, errors or skips (50 seconds); offline app `compileKotlin` +passes (8 seconds). Twenty-two JS and nine actor/native-wiring mutations are caught. +The matching beta-4 trace records 42 bridge invocations versus 12, 26 content commits versus 12, +and 25 distinct presentations versus 11 over twelve gestures. First invocations occur at 25–36 ms +versus 84–90 ms; this supports more frequent delivered updates, without establishing terminal FPS +or end-to-end SSH latency. Both traces and queries are retained in the private beta-4 evidence. + +`fed68fb3` (`A88`) keeps one expected certificate and verified v2 while accepting newer SDK-range labels; +32 Python tests and ten mutations pass locally (32 tests, zero failures, 16.8 seconds). The observed CI packaging failure still requires a +successful subsequent workflow before claiming CI repair verified. + +Private beta `0.1.0-beta.4` / code `5` uses source +`3cffb49d8cf64932260e914b42b3883331d0352d` and the retained signer. +The code-5 local AGP release built in 48 seconds and passed every R8 keep. Retained-signer +packaging verified non-debuggable APK metadata (minSdk 26, targetSdk 35), signature, alignment +and source/hash provenance. APK SHA-256: +`c64d6a8dea9621265f23a679104149e511ecd243fbdfa53ced401f4cb4f0f6b5`. +The intended Pixel updated in place, preserving manual SSH configuration/key/pin and granted +notifications; the installed app remains non-debuggable. Its owned 56×48 terminal now reaches +positions 25, 45, 70, 100, 130 on the same five 1000-native-pixel/350-ms swipes, versus beta 3's +5, 10, 15, 20, 25. Each gesture produces 4–6 observed host-position changes, reversal moves +130 to 110, and the actual Esc chip leaves copy mode. Private inputs/proof are in +`.nodeterm/android-beta-build-4/` and the APK/checksum/metadata in `.nodeterm/android-beta-4/`. +These verify delivered movement/order/cancellation, not terminal FPS or satisfactory user feel. +At the beta-4 checkpoint, user follow-up remained open under `A86` and kinetic fling was absent. +Push is authorized, +with required pre-push checks and green Android workflow afterward; no PR is requested. + +### Historical scroll mitigations implemented; private beta-3 updated on the intended Pixel (2026-10-02) + +`e6bdb157` enables TCP_NODELAY on connected SSH sockets. `245b42e6` measures row height once per +gesture, accounts for stock tmux's five rows per wheel notch and batches same-direction movement +by animation frame, preserving fast-swipe distance in ordered calls of at most 20 notches. +`2c5d15a8` adds one bounded serial `TerminalActions` drain per accepted stream. Suspended relay +RPCs preserve direction/input order; input cancels unsent scroll and follows the in-flight call. +Retiring the viewer cancels/clears its queue, while native raw/resume writes cancel page scrolling +first and remain bound to the accepted stream. `40c4ee49` cancels page scrolling again immediately +before delayed paste Enter. No external host verb, payload or SSH-visible file contract changed; +this fix needs no iOS payload/interop fixture update. + +The full restored-source protocol suite passed 638 tests in 61 suites, with zero failures, errors +or skips (51 seconds); the final offline app `compileKotlin` passed (7 seconds). Thirty mutations were caught: +two SSH socket-policy, fourteen JavaScript and fourteen actor/native-wiring mutations. +Private `0.1.0-beta.3` / code `4` from `40c4ee49592e2f92fc7e6e9b548ba88a33b1e2d3` built locally +in 49 seconds, passed every R8 keep and retained-signer packaging. Signature, alignment and +source/hash provenance were verified with all 149 ZIP payloads unchanged by signing. Its in-place +update preserved manual SSH registration/key/pin and notification permission; the app reopens the +Linux host over SSH with a 56×48 owned test terminal. Five identical controlled swipes finish at +history position 25 versus beta 2's 85; reversal moves 25 to 20 and the actual Esc chip leaves copy +mode. The phone returned to Sessions and refreshed; only the owned test session was removed, with +all user panes untouched. Private proof/screenshot/package checks are in +`.nodeterm/android-beta-build-3/`, including `device-checks.json`. The user subsequently reports that beta 3 still has both lag and too little movement; the +current correction and controlled phone trace are above. Custom tmux wheel bindings and kinetic +fling remain open. `A86` stays open for those checks. Push is +user-authorized, branch CI triggers remain enabled and every requested push requires a green +Android workflow verification. No PR is requested and `A68` remains deferred. + +### Post-beta scroll responsiveness investigation (2026-10-02, before mitigation) + +After beta checkpoint `cf0487a3`, the user reports slow scrolling on both Wi-Fi and mobile-data VPN +(`A86`), so a VPN-only cause is not supported. Controlled private +SSH/tmux measurements show five rows per wheel notch, coarse drag gain, no fling and clipping +above 20 notches per call. TCP_NODELAY improves some loopback output-tail measurements but does +not explain every rate. Desktop DOM replay shows no backlog at that load; the phone's renderer +and real-network timing are unmeasured. Next compare +gesture gain/coalescing/order, socket behavior and phone renderer traces. This is diagnosis only; +no source, phone setting or installed APK changed. The signed-beta evidence below still holds. + +### Intended Pixel beta updated; direct SSH and pre-attach tmux history verified (2026-10-02) + +The user supplied the intended phone's ADB endpoint: Pixel 10 Pro, Android 17 / API 37, Vanadium +WebView `154.0.8037.92.0`. Beta `0.1.0-beta.1` / code `2` installed, the user granted notifications +through Android's normal dialog, and `run-as` is denied. The phone's SSH public key was authorized +without replacing existing keys. Add SSH server connected to the intended Linux host, with its +Ed25519 pin matching the host's public key. The app lists 17 actual projects, and an `echo` sentinel +entered from the phone executed in a controlled temporary test tmux window. No user pane received +test input. This verifies basic manual direct-SSH registration, browse and input; QR/code or relay +pairing was not tested. The user subsequently confirmed mobile-data WireGuard terminal access; +reconnect/background/answer behavior and the full 64-item checklist remain open. + +The terminal advertised 52/56 columns by one row despite a large viewport, including after a font +change (`A85`). Chromium forces zero CSS layout height when WebView layout parameters use +`WRAP_CONTENT`, even with large native bounds. `febe022a` supplies `MATCH_PARENT` width/height +before loading the terminal page, without CSS or JavaScript changes. The real Gradle +`TerminalWebViewLayoutTest` wiring guard passes and two layout-policy mutations are caught in a +temporary source mirror. Actual `0.1.0-beta.2` / code `3` from +`febe022ad2fc373f27ac11d9ad5f130f36f027a5` passed its offline AGP release build in 45 seconds, +retained-certificate signature/alignment/provenance verification and `adb install -r`. Host +configuration, phone SSH key, host pin and notification grant were preserved, and the app reopened +the intended Linux host over SSH. Inputs/logs are in `.nodeterm/android-beta-build-2/`; +APK/checksum/metadata are in `.nodeterm/android-beta-2/`. APK SHA-256: +`a022a399e23c81607a4a3664862b964ad781f0a589ab77dda902b4c2dc597eca`. + +The controlled terminal now fills a 52×45 viewport, versus 52×1 before. A downward swipe entered +tmux copy mode at position 82, and a screenshot visibly showed the pre-attach ready/sentinel and +marker rows 001–039 from 120 rows printed before update/attach. This restores pre-attach tmux +history through the layout fix; production SSH scroll is unchanged. A− restores font 13 and 56×48; +showing the soft keyboard gives 56×25 with native bounds `[0,396][1280,1462]`, and hiding it returns +56×48. Esc leaves copy mode (`pane_in_mode=0`), and Send executes a second unique harmless draft +command with its whole output line visible. The app returned to Sessions; only the owned temporary +test window was removed, with the previous window restored and original Python process alive. +The intended phone's app/key/configuration remain. Device-check metadata and controlled screenshots +are retained in `.nodeterm/android-beta-build-2/`, including `device-checks.json`. The full 64-item +phone checklist remains open; user-reported mobile connection evidence follows. + +**User-reported mobile check:** with WireGuard enabled and Wi-Fi off, the user confirmed that the +intended Linux host's terminal opens over mobile data. This is separate from the ADB-assisted LAN +checks above. Mobile reconnect, approvals/questions, background behavior and the full 64-item +checklist remain open. + +History regressions in `d6619bf6` pass 47 focused real Gradle SSH/terminal/link tests with zero skips. Both JavaScript swipe-direction/disabled-scroll mutations, the real-SSH wheel-direction mutation and the two native layout-policy mutations were caught; production sources were restored. **Final verification:** all 609 protocol tests passed in 59 suites with zero failures, errors or skips (52 seconds); the offline app `compileKotlin` passed (7 seconds). + +### First private beta built and signed; wrong-device launch rolled back (2026-10-02) + +The actual release source `fa71cb08072f399f24a81bfb361ea852a0275f3b` builds with AGP 8.10.1, +Kotlin 2.2.0, wrapper Gradle 8.14.3 and JDK 21. The first AGP 8.9.1 build completed with many R8 +Kotlin-metadata warnings (`A83`); the corrected release removes them. Final offline +`:app:assembleRelease` passed in 15 seconds and every R8 runtime keep matched. The toolchain guard +passes eight bounded tests and catches two mutations. + +The retained signer signed and verified `0.1.0-beta.1` / code `2`, with expected certificate pin, +`debuggable=false` and same-build local source/APK/R8/hash provenance. Inputs/logs are in the +original workspace's ignored `.nodeterm/android-beta-build-1/`; the finished APK/checksum/metadata +are in `.nodeterm/android-beta-1/`. Installation on the MI8 (Android 15 / API 35) succeeded, and +the first cold start reached the notification permission dialog. With `POST_NOTIFICATIONS` granted, +the app shows an empty Computers screen, Pair button and Settings, with no crash markers. Installed +metadata confirms version code `2`, minSdk `26` and targetSdk `35`; `run-as` is refused because the +app is non-debuggable. WebView is `com.android.webview` `144.0.7559.76`. These are partial evidence +for checklist items 1 and 5 on that test device; neither entire item has passed. + +The user identified the MI8 as the wrong phone. Only the newly installed app and its test UI dump +were removed. Its newly authorized SSH public key was removed, and the host's `authorized_keys` +was restored byte-for-byte to the pre-change state. No host was paired on the MI8 and no SSH +connection was attempted. At that point intended-phone installation and registration were open; the user's target +is this Linux host over their WireGuard VPN. A50's signed-artifact preparation is addressed, but +pairing, terminal use, mobile-data preflight and the full 64-item phone pass remain open. + +Full `npm run typecheck` and 679 desktop tests passed, with three platform skips; the offline +Gradle app type-check and 23 Python beta-tool tests passed. The real Gradle protocol run executed +605 tests (603 pass, two real-SSH failures, zero skipped). The failures are test isolation faults +(`A84`): Fedora's login-shell command-not-found handler delays the literal `-R` command, and a +previous no-exit-status test leaves Escape in shared Readline state, changing the next `echo` to +`cho`. The test-only fix in `1d6b04cc` isolates a fresh non-login pane and initialization environment +per test. The final full rerun passed all 606 tests, with zero failures, errors or skips; both +harness mutations were caught and the fixed source restored before the rerun. App source/build +`fa71cb08` is unchanged by this test fix. The user authorized local +build/install instead of requiring Actions; no current CI pass or PR is claimed, and A68 remains +last. Earlier progress entries describe the restricted sandbox and have not been rewritten as +new validation results. + +### Local host access restored; source reconciliation (2026-10-02) + +The user authorized a local build instead of waiting for GitHub Actions. Restored host access +allowed fetching remote tip `82940e17`, whose six batch E follow-up fixes were reconciled in `990f90c6`. The host-side adb lists an MI8 phone on Android 35, with no installed +`dev.nodeterm.android`; no installation or device checklist result is claimed yet. Build the +unsigned minified APK and record APK/R8/source provenance from one successful local build, then +package with the retained signer. Required checks on the reconciled source, the actual APK and +phone validation remain pending. Earlier entries describe the prior restricted sandbox. + +### First private signing identity prepared (2026-10-02) + +The user confirmed this is their first private beta. A retained RSA-3072 PKCS12 signer, alias +`nodeterm-beta`, is prepared in the original workspace's Git-ignored +`.nodeterm/android-beta-signing/` (outside the temporary source checkout). The directory is `0700` +and every file is `0600`. Separate local password files feed `keytool`/the packager without putting +passwords in arguments or logs. The keystore contains a `PrivateKeyEntry` with a 3072-bit RSA key. +Its exported public certificate hashes to the locally recorded signer pin and differs from the +committed public debug certificate. Keep this directory in a private backup: source exports +deliberately exclude it. + +At this stage the signer resolved one prerequisite of `A50`; no actual nodeterm APK had yet been +built or signed. GitHub DNS, Gradle's lock-service sockets and adb/USB access were still unavailable. +Next obtain one final green CI run's APK/R8/provenance, package with this retained signer, then +install and complete the mobile-data preflight/device checklist. No PR is opened; `A68` stays last. + +### Private beta preparation (2026-10-02): local pipeline and reliability fixes + +The user wants a privately sideloaded APK for their own phone. A signed APK is not published to +Actions or a public release: artifacts on a public repository are downloadable by other signed-in +users. `android/tools/package-beta.py` signs the unsigned AGP release locally, rejects the public +debug key and unexpected signer, checks release metadata, R8 keeps and APK alignment/signature, +then emits APK/checksum/provenance together. Version overrides leave ordinary builds unchanged. +Pushes to this exact takeover branch build versioned unsigned beta inputs and run **Private beta +checks**; optional manual **prepare_beta** inputs work once the workflow exists on the default branch +(a GitHub requirement; cached main has no Android workflow). The beta checks require the +protocol/release jobs, desktop type-check and delivery/ack tests, and +real-tool packaging regressions. See the [private-beta procedure](../android/README.md#private-beta). + +- `A81` (`86390a49`, `7e11e93c`): relay join/device HTTP calls have a 30-second total deadline and finite I/O + timeouts, including dispatcher queueing. Coroutine cancellation closes the call through + response-body consumption and preserves a caller's shorter deadline. Six real + OkHttp regressions pass over in-memory sockets; three initial and three follow-up mutation checks + are caught. WebSocket reads remain + long-lived and request/response shapes stay the same. Full Gradle/backend/device checks pending. +- `A82` (`010240e0`): read acknowledgments are consumed only by a positive owner. Local mirror ownership includes + unresolved own inbox cards after restart; a retained foreign file is retried if ownership changes. + Remote ownership aggregates every connected project on the host before the shell reads/removes + files. Android's existing producer contract is covered in the same change and remains compatible + with iOS; @eneskirca should verify the multi-desktop behavior on iOS. +- Packaging uses fixture APKs and disposable test keys for regression tests. Those fixtures are + **not the nodeterm app**. At that stage no APK had been built, signed or installed for the user. Required + full checks, latest-remote reconciliation, CI and all 64 device items remain open. `A68` is deferred. + Packaging commits `d5f561ec`, `817fd923` pass 22 SDK-backed tests and 13 mutations. The selected + beta-version environment guard passes 12 cases and catches its bypass. Ack verification passes + 225 desktop tests, three cached-compiler interop/path checks and eight mutations. CI config passes + seven checks/six mutations in `6efde5f1`, and existing device/contributor docs pass nine checks. See `android.md` + for the cached compiler/dependency limits; full required checks remain blocked before execution. + +### Continuation (2026-10-02): local fixes, full checks and device pass still pending + +This session started from the locally cached branch tip `6afd8f53`. GitHub access failed, so it +could not fetch a newer tip, download a CI APK, push or confirm a workflow run. Commit signing +also cannot access its key service here, so the continuation commits are unsigned local commits. The original +checkout's Git metadata is read-only; the working branch is in `/tmp/nodeterm-android-takeover`. + +- `A78` (`bb5b3e54`): desktop quick answers resolve the exact session to a pane ID, cancel copy mode there, + and deliver to that same pane. Mounted tmux sessions use that path too. Whole deliveries are + ordered per node; a failed or unconfirmed operation is never retried on another channel. +- `A79` (`e64665c3`): Android's SSH quick answers also pin the pane ID and cancel copy mode before typing; + lookup, cancellation, send failure and missing SSH exit status cannot report success. +- `A80` (`0f39c33f`): a control-mode client's startup attach reply is consumed before replies to queued + commands. An attach failure retires the client and rejects queued work. +- At that stage no APK had been built or installed. See "What is verified" in `android.md` for + the local checks and their limits. The full protocol suite, app type-check, desktop type-check, + real tmux/SSH checks and device pass still need an environment that can run them. + +### Batch E (follow-ups, desktop + phone): done on the branch, not device-verified + +Six follow-ups from earlier batches. Each had one or two adversarial reviews and a follow-up commit +for every finding above "nit"; all were confirmed against the code before fixing. Four of them +change the desktop, and three add wire fields (all additive, so older phones and iOS are +unaffected until they read them). + +| Item | Commits | What changed | Checked by | +|---|---|---|---| +| A12 (SSH projects) | `8430243` | `node.sendKeys` types into a node of the desktop's SSH project over that project's ControlMaster (`PtyManager.backgroundWriteOver`, `send-keys -H` to the exact `=nt-:` pane, copy mode cancelled first). Master down, no remote support or a failed resolve answer `sent:false`; never the local socket. Wire unchanged. | vitest `host-node-actions.test.ts`, `pty-background-write-over.test.ts`, `remote-send-keys.realtmux.test.ts` (the generated line under real `/bin/sh` + tmux, mutation-checked); `RelayInteropTest`. | +| A07 (revoke) | `c199349`, `a988a30` | Revoking a paired phone closes its live relay sessions through the standing host's own Deny path and withdraws a pending consent for that key; other keys are untouched. An unpin that could not be written puts the device back in the list (Revoke retries) instead of reporting success; Settings says when the cut could not be confirmed. | vitest `standing-host.test.ts`, `pairing-service.test.ts`, `peer-revoke-wiring.test.ts`, `PhoneSection.revoke.test.tsx`. | +| A07 (late adoption) | `f66fddf`, `07b7276` | A phone paired while remote access was off is approved on its first relay handshake: the desktop pins a box key that a listed pairing recorded (sealed `/pair` body), re-asked inside the pin queue so a revoke cannot be undone. The `/pair` answer gains `relayApproved`. The host holds the phone's first requests (at most 5 s / 32 requests) until it has decided, so a phone about to be approved silently is not told "awaiting approval" first; a background dial may therefore make the first relay handshake for a pairing that answered `relayApproved`. No new SSH-visible file. | vitest `approved-devices.test.ts`, `pairing-core.test.ts`, `paired-phone-late-pin-wiring.test.ts`, `host-service.decision-hold.test.ts`; `PairingInteropTest`, `RelayInteropTest`. | +| A49 (anchor) | `513c166`, `402f139` | The sealed `/pair` answer carries the computer's SSH host key fingerprints (`sshHostKeyFingerprints`, read from `/etc/ssh` and sshd's `HostKey` lines; never in the QR or a plaintext answer). The phone's first SSH connect accepts only a named key (`HostKeyNotPairedException` otherwise, routed like a changed key with its own advice). | vitest `ssh-host-keys.test.ts`, `pairing-service.test.ts`; `HostKeyAnchorsTest`, `SshFallbackTest`, `PairingInteropTest`, `SshTransportTest`. | +| A74 (refresh) | `cb12f3b`, `898d937` | `projects.list` over the relay answers `{output, lan?}` with the computer's current LAN IPv4 and host key fingerprints beside the blob (not inside it). The phone takes them only from a RELAY listing: a new address replaces the stored one, and the reported keys become the anchors. A host certificate is named by the key it certifies, so it no longer flips the pin. | vitest `host-lan-report.test.ts`; `LanRefreshTest`, `HostCertificatePinTest`, `RelayInteropTest`, `SshTransportTest`. | +| Seen log per computer | `6afd8f5`, `a65e12f` | The notification seen log is keyed by computer, and an event that reaches the phone through two pairings (a desktop and the SSH host it drives) still notifies once (same id and node). The old store migrates as seen for every computer until it ages out; forgetting a computer drops its entries. | `SeenLogTest`, `LiveNotificationsTest`. | + +### WP5 batch D (iOS parity features): done on the branch, not device-verified + +Each item had an adversarial review and a follow-up commit; the review findings were all checked +against the code before fixing. Most rules live in pure `android/protocol` classes; the Compose +screens are type-checked and source-pinned only. + +| Finding | Commits | What changed | Checked by | +|---|---|---|---| +| A26 | `9cdc4cf`, `1eeb5b8` | `LegRouting` decides per capability (board writes, git, node actions, register node, answer approvals) whether the primary leg serves it, the relay leg opened next to SSH does, or it is unavailable with a reason that names the fix (remote access not set up / off on the computer right now / not picked up yet / the "Only on my network (SSH)" route). The browse reports whether `~/.nodeterm/relay.json` is there, so a held token with remote access switched off reads as off. Availability is a presence check (`hasRelayToken`), never a Keystore decrypt while composing. New session and board controls are disabled with the reason instead of failing on tap. | `LegRoutingTest`, `HostBrowseTest`; source pins on the screens. | +| A29 | `0c5a1e1`, `a5f38f5` | A per-project Source Control screen over the desktop's `git.*` verbs (status, diff, stage/unstage, commit, history), routed by `LegRouting` (relay only; no SSH git). Merge conflicts get their own section (no Stage; Commit refuses until resolved), combined-diff markers are read, and long git operations get the desktop's own timeout. | `SourceControlTest`; `RelayInteropTest` drives the desktop's real `GitService` over a temp repository. | +| A32 | `7035bde`, `88beed2` | terminal.js ports the desktop's wrapped-row URL matcher (not addon-web-links), handles OSC 8 through `linkHandler`, and opens http(s) only behind a link bar; a "Copy lines" sheet reads `term.buffer`. Overlays over the terminal stop touches from reaching the WebView or the input bar (`blockTouchesBelow`). | `TerminalJsLinksTest` runs the real terminal.js under node; source pins. | +| A25 (in-app part) | `4ffb8b7`, `0d310cc` | Inbox notifications carry Approve/Deny and option actions, run by a worker through the same `QuickActions` re-checks as the in-app card, never making a first relay handshake. A stale notification cannot type into a newer prompt (the card must still be listed and unresolved), and an interrupted worker never sends an answer twice. A tap opens that session's terminal with the computer's Inbox one Back away; an action that cannot answer from outside the session updates the notification to say so (Android forbids starting the session from a worker or receiver). **FCM is still missing** (backend). | `NotificationActionsTest`, `QuickActionsTest`. | +| A55 | `71b592a`, `0772cbf` | Each computer row shows its needs-you count from the snapshot it already holds (no dialing); with two or more computers an "All computers" screen merges Inbox and Usage, with a per-computer status strip that shows a failed listing and its Try again. | `AllComputersTest`; source pins. | +| A59 | `57804cf`, `8020796` | A mic button in the terminal input bar uses `SpeechRecognizer`; the words go into the draft (cursor after them) and are never sent. No on-device Whisper and no `/v1/transcribe` (that endpoint does not exist yet). | `DictationTest`; source pins. | +| A27 | `8691e6d`, `9b5c342`, `1d8201f`, `c450e16` | (a) The direct-SSH browse reads a Server Edition (`~/.nodeterm-server`) and a host a desktop drives over SSH (both tmux sockets, the per-project status slices); a node no listing names is never offered the relay, so a phantom `nt-` cannot be created. (b) "Add SSH server": add a computer by address, show the phone's public key line for `authorized_keys`, pin the host key after auth, SSH only; the `ssh-` id prefix keeps an added computer added across an older build's rewrite. | `HostBrowseTest`, `ManualHostTest`, SSH transport tests. | + +### WP6 batch C (CI, test harness and docs): done on the branch + +| Finding | Commits | What changed | Checked by | +|---|---|---|---| +| A60 | `67e6297` | The interop bundle aliases `electron` to a throwing stub instead of loading the real package (whose binary was downloaded at test time); a failed fixture start kills its node process. | `InteropHarnessTest` (no `require("electron")` in the bundle; a never-ready start leaves no process). | +| A62 | `77c760d`, `bf24cfc` | The SSH tests make a short, realpath'd tmux socket root (macOS's 103-character limit) and drive either util-linux or BSD `script`; POSIX-only helper tests skip elsewhere. | `SshTestHostTest`; not run on a Mac. | +| A70 | `68d5da6` | The fixture is bundled through esbuild's JS API by `node` (no `.bin` shim for CreateProcess); the pairing fixture pins its platform and the test isolates `USERPROFILE`. | Bundle byte-identical to the old CLI output; not run on Windows. | +| A67 | `696fd10` | `tsconfig.node.json` includes the interop fixture, so `npm run typecheck` checks it; `HostSessionOptions.pty` narrowed to `HostPtyManager`, removing the fixture's cast. | `npm run typecheck`; a guard test pins the include and bans the casts. | +| A63 | `b3d4c6e`, `9d19bd1` | `android.yml` runs on every file the fixture bundles (`src/core/**`, `src/shared/**`, `src/main/*.ts`, `src/main/remote/**`, `package*.json`, `tsconfig.json`) and on the docs the tests read; the list is derived from esbuild's metafile. | `WorkflowPathFilterTest` re-derives it. | +| A64 | `f9782da` | The relay `projects.list` blob comes from `buildProjectsListBlob` (one assembly for `index.ts` and the fixture); mirror entries from the real writer. Docs now say which contracts are still hand-copied. | vitest `projects-list-blob.test.ts`; a guard test refuses a hand-written blob. | +| A69 | `6f3a8da`, `4806b92`, `86d059d` | `setup-gradle` (wrapper validation, a read-mostly cache) in every Gradle job; Dependabot `gradle` for `/android` with the network/crypto stack in its own group; a Kotlin CodeQL job built under CodeQL (`build-mode: manual`), moved into `android.yml` by `86d059d` so a branch push runs it (`security.yml` has no `workflow_dispatch` and runs only on `main`/PRs/the queue). The gradle-8.14.3 distribution checksum is not pinned yet (no network here). | `GradleCiCoverageTest`. | +| A61 / A71 | `39e7995`, `9d19bd1` | Docs: an existing `npm install` is enough for the interop tests; `npm ci --ignore-scripts` is CI's and replaces `node_modules`. JDK 17–24 (Gradle 8.14.3 cannot start on 25). | `ContributorDocsTest`. | +| A65 / A50 | `33af5e7`, `d092639` | `docs/android.md` gets the numbered device checklist; the READMEs say the debug APK is debuggable, and that a phone exposed over adb needs a new identity (uninstall or clear storage) before re-pairing, not just re-pairing. A signed non-debuggable release is still open (`A50`). | `DeviceChecklistDocsTest`. | +| A66 | `61e218b` | The desktop's Android link reads "nodeterm for Android (build from source)" and is derived from `REPO_URL`. The link resolves once `android/` is on upstream `main`. | vitest `androidAppLink.test.tsx`. | + +### WP6 batch B (app-only low-severity items): done on the branch, not device-verified + +Most of the logic moved into pure, JVM-tested classes in `android/protocol` (the Compose wiring is +type-checked and source-pinned only). Every item had an adversarial review; the follow-up commits +are listed with it. + +| Finding | Commits | What changed | Checked by | +|---|---|---|---| +| A40 | `a38d39d`, `5fb3aea` | `TerminalHandoff` (StreamLease, ViewerSlot, PhoneLaunch): a stream is installed only for the current attach ticket; a phone launch holds its own lease, so Back or backgrounding within the settle delay still writes the line and registers the node. Review follow-up: once a request is on the wire it is not cancelled, and both transports now detach a stream whose caller was cancelled (relay `pty.kill`, SSH client close). | `TerminalHandoffTest`; relay interop and SSH tests for the cancelled attach. | +| A41 | `94a558a`, `0507555` | `InputBar.plan`: Send keeps the draft while detached; byte-sending chips are disabled. The resume offer survives a reattach of the same screen and is re-checked against a fresh listing before it can be tapped. | `InputBarTest`, `ResumeOfferTest`. | +| A45 | `1901939`, `a371483`, `46b0897` | `onRenderProcessGone` handled: a kill reattaches automatically once a fresh WebView reports its size (bounded, visible screen only, monotonic clock); a crash offers "Reopen terminal", and the WebView is rebuilt only when something asks to attach. Renderer priority IMPORTANT, waived when not visible. | `TerminalPageTest`. | +| A46 | `17ee526` | The ⌨ chip clears Compose focus, focuses the WebView, blur/refocuses xterm, and asks the IME after a frame. | Source pins; device check owed. | +| A76 | `6966f25`, `4ef21f0` | "Wake " over direct SSH for a Sleeping node, only while a shell owns the pane (read before offering and again at the tap), with Ctrl-U first. **Desktop:** the mirror (and the renderer's self-heal report) now drops `hibernated` on a live state or session start, so a CLI resumed outside the desktop's own wake no longer stays Sleeping. | `ResumeOfferTest`; SSH transport test with a real tmux; vitest mirror + agentStatus tests. | +| A43 | `2be5e87` | `BackStack`: per-entry keys for `SaveableStateHolder`, retired keys removed (persisted across process death); Board project and Inbox archive toggle saveable. | `BackStackTest`. | +| A44 | `7988087`, `582bdae`, `46b0897` | `ApiBaseSetting`: system back saves like the arrow; an unedited address is not stored (the built-in default stays a default); leaving by a notification tap or a pairing link saves too; a refused or unusable stored address is flagged in the field. | `SettingsLeaveTest`. | +| A42 | `7debe67` | `NewSessionChoice`: the selection is derived from the current listing; Start is disabled when nothing is offered. | `NewSessionChoiceTest`. | +| A57 | `8dbeb48` | Multi-select questions list their options read-only with "answer in the session". | `QuestionChoicesTest`. | +| A47 | `52df0a3` | The host list asks `SecretStoreCore.contains` (no decrypt, no lock) keyed on a revision flow. | `SecretStoreCoreTest`. | +| A52 | `3780f5a`, `46b0897` | `InboxNotificationText`: no event text in notifications by default ("Show details in notifications" opts in); a public version for lock screens that hide sensitive content. | `InboxNotificationTextTest`. | +| A73 | `e055f37`, `27ee194`, `46b0897` | Live notifications for the watched computer: every successful listing announces, skipping what is on screen (the Inbox tab; the open terminal's node, except a held hook-reply approval, which is not painted). A computer the user left is not announced from pushes. | `LiveNotificationsTest`. | +| A51 | `9b4af70`, `46b0897` | `data_extraction_rules.xml` excludes everything from cloud backup and device transfer; a box key created anew drops the device id so a new one is minted with it. | `PhoneIdentityTest`; device check owed. | +| A77 | `0a2a1aa` | `Modifier.aboveKeyboard`: Scaffold padding, consume, then IME padding, on the terminal, Pair and Settings screens; no `enableEdgeToEdge()`. | Source pins; device check owed. | +| A37 | `ac0923a`, `e0a7883` | Real R8 rules (`-dontwarn` for what jdeps finds missing, keeps for the WebView bridge, name-loaded crypto classes, the worker); release is minified; CI builds `assembleRelease` and checks the named keeps matched. A missing `-dontwarn` fails CI, a missing keep for new reflection does not. | CI job "App release (R8, unsigned)" green on its first run; `R8RulesTest`. | + +### WP4 remainder, WP5/WP6 batch A (desktop root causes, protocol-testable items): done on the branch, not device-verified + +| Finding | Commit | What changed | Checked by | +|---|---|---|---| +| A13 (desktop) | `b718a04` | The app's own tmux client attaches with `-A` and no `-D` while a relay-served client of the same node is live in this process (found by walking the session table at spawn time: local tmux, sink attached, not over SSH, not the session host). With no phone attached the argv is unchanged. The remote (SSH-project) attach never used `-D`; now pinned by a test. | vitest `pty-relay-coattach.test.ts` (argv, both orders, remote) and `relay-coattach.realtmux.test.ts` (real tmux in the sandbox: `-A -D` kicks a control-mode client with exit 0, `-A` keeps both). Mutation-checked. | +| A72 | `71290de` | A phone-started session (A33's `projectId`/`agentId` on `pty.attach`) now gets the agent-gated hook env (`NODETERM_AGENT_ID`, the approval wait, canvas control by `canControlCanvas`) and a proven pane owner, resolved on the host from the index entry the project id matched. Resolver moved to `host-new-sessions.ts`. | vitest `host-new-sessions.test.ts`, `pty-relay-create.test.ts`, `remote-security.test.ts`. | +| A39 / A75 | `437e359`, `ea19490` | Additive `label`/`email` on the mirror's `settings.claudeAccounts`, written by one shared builder in both shells and the SSH slice. One protocol helper names accounts: settings label, usage label, email, then `Account ` — never the full UUID; an unlinked config dir shows its last path segment. Used by the picker, the sessions row and the usage card. | Protocol `AccountNamesTest`; vitest mirror test; the interop fixture's settings entry is built by the real desktop builder. | +| A38 | `5ff8f6c` | A ticketed approval is answered while the node is WAITING on a held parent question (the desktop's "gone" still guards an expired hold). The keyed paths keep their exact-state gates. | Protocol QuickActions tests. | +| A56 | `38fa6c4`, `45e21ec` | **Not built, by decision.** A static read of Claude Code 2.1.283 shows option 2 of the permission prompt is conditional: when there is no "don't ask again" row, `2` is "Yes, and switch to auto mode" or "No". A blind `2` can therefore deny or widen permissions. Documented in `docs/android.md` (Known gaps) and `docs/hook-reply-approvals.md`, including that **the iOS "Always allow" has the same hazard** (for @eneskirca). The safe route is a hook-level allow with `updatedPermissions`, which needs a cross-surface design. | Docs only. | +| A58 | `a44f16c`, `144bd40` | Usage pace line ("5h usage pace slower/faster", "on pace" within ±5 points), computed at the usage snapshot's own time, refusing stale or unknown windows; a context ring on approval, question and done cards. | Protocol `UsagePaceTest` (15 cases). | +| A48 | `d383e76` | The seen log is id → time, pruned by age past the 6 h announce window (+18 h margin), claimed and marked under one lock; the old string set migrates as "seen now". | Protocol `SeenLogTest`. | +| A53 | `af587ac`, `ec203db` | OSC 52: the `;` is required, a 16-character selection field at most, 100,000 characters of text (400,000 base64) at most, strict base64 and UTF-8; terminal.js applies the cap before the bridge; a failed or too-large clipboard write shows a toast instead of crashing. | Protocol `Osc52Test`; `TerminalJsOsc52Test` runs the real terminal.js under node. | +| A54 | `32330df`, `ec203db` | `/pair`: a negative, non-numeric or over-64 KiB Content-Length is refused, a length-less body stops at 64 KiB, a 45 s watchdog closes the socket (also on cancellation), and a refusal body is shown as one line of at most 300 characters. | Protocol `PairingClientBoundsTest`; an interop test measures the real answer (606 bytes with the fixture's short token). | +| A49 / A74 | `a40d11b` | The SSH host-key pin is persisted only after public-key auth succeeds. In Auto, a changed host key refuses SSH, shows a warning, and falls through to the relay leg; SSH_ONLY keeps the hard stop. The message points at Settings → "Only through the relay". | SSH transport test (a failed auth does not pin); protocol `SshFallbackTest`. | + +### WP4 (medium bugs): done on the branch, not device-verified + +| Finding | Commit | What changed | Checked by | +|---|---|---|---| +| A24 | `8e304db` | `SecretStoreCore` (protocol) replaces a stored secret only on positive evidence it is gone (nothing stored, malformed blob, `AEADBadTagException`); anything else is `SecretUnavailableException` and nothing is written. The identity's first write is durable. `SecureStore` generates the Keystore key only when the alias is absent. | `SecretStoreCoreTest` with real AES-GCM. | +| A20 | `d199f03` | `connectLocked` rethrows cancellation on both legs (state back to Idle, no "offline"), and closes an SSH connection that finished dialing after its caller was cancelled. | Type-check + CI build only. | +| A11 / A19 | `de1eded` | `onNewIntent` records the host id (and `setIntent`); a LaunchedEffect opens Hosts → that computer's Inbox. A re-navigated Host screen starts on the Inbox tab (the per-entry state holders of A43 now do what a `key(route)` did here). | Type-check + CI build only. | +| A34 / A36 | `2a273a7` | A34: an armed Ctrl applies to the input bar (`Keys.ctrl`, one control byte, no Enter). A36: a null exit (connection gone) reattaches automatically once the host connection is back — bounded (3 per flapping stretch), only while the screen is showing. | `KeysTest`; the controller is type-checked only. | +| A14 / A15 / A16 | `c58ad65` | A16: the project's `defaultPermissionMode` wins over the global one (both re-validated); its `defaultAccountId` is preselected only while the host still has it. A15: the resume offer is built like the desktop's cold restore (`cd`, `CLAUDE_CONFIG_DIR`, mode; portable `./` cwds resolved). A14: cwd-less projects are no longer offered, and a refused registration shows a notice. | `ModelTest` (4 new cases). | +| A12 | `ab1335c` | New additive desktop verb `node.sendKeys {nodeId, keys}` → `{sent}` via `PtyManager.backgroundWrite` (no throwaway client; short answers only; SSH-project nodes answer `sent:false`). The phone uses it; `sent:false` opens the session; an older desktop gets attach → wait for paint → write → 400 ms linger. | vitest `host-node-actions.test.ts`; relay interop incl. the older-desktop fallback. | +| A31 | `a622e71` | sshj `KEEP_ALIVE` (want-reply, 15 s × 3 misses) instead of `HEARTBEAT`; `run()` has a real deadline that tears the transport down; a command that cannot run drops the connection. HostScreen shows a listing error while connected. | SSH transport test: a hung command returns within its deadline and fires `onClosed`. The keepalive-miss path itself is not exercised. | +| A18 | `10de4b9` | `LifecycleStartEffect` in HostScreen and TerminalScreen: watching and the terminal stream stop on ON_STOP (WebView paused) and resume on ON_START. | Type-check (new stub) + CI build. | +| A21 | `daf15d3` | The switch shows On only when the pref is on AND `areNotificationsEnabled()`; switching on asks for the permission or opens the app's notification settings; the worker does nothing when nothing can be shown; the launch-time ask happens on a fresh start only. | Type-check + CI build only. | +| A22 | `ade7428` | The back stack is `rememberSaveable` (JSON Saver); pairing routes are not restored; routes naming a forgotten computer are dropped; the launch intent applies only on a fresh start. | Type-check + CI build only. | +| A13 | `68d0925` | **Phone side only.** An exit 0 while the session is still listed as live (another client attached with `-D`) reattaches instead of reading "ended". The later A13 host follow-up below fixes the root cause; physical checks remain pending. | Type-check + CI build only. | +| A33 | `0db0b6e` | New optional `pty.attach` fields `projectId`/`accountId`/`agentId` (additive). The desktop resolves the project folder and a local, logged-in managed Claude account itself, applied only when the attach creates the session. The phone sends them for a session it starts. | vitest `remote-security.test.ts` (3 new cases); relay interop through the real handler. | + +**Settings visible flow passes; item 49 remains Partial (A44, 2026-10-04).** On installed beta 10, +a temporary phone name and safe HTTPS loopback API persist after actual system Back and reopen. +An invalid HTTP address shows its field error after keyboard dismissal and a visually reviewed rejection toast; reopening +retains the prior valid HTTPS value. Reset to default, system Back and reopen restores the built-in +API; the original phone name/API are then restored and verified. Unedited leave/reopen preserves +the visible values. Internal preference-key absence and absence of writes were not physically +inspected on this nondebuggable app; existing `SettingsLeaveTest` behavior/source guards cover +those rules. The strict device ledger therefore marks item 49 Partial, giving 10 Pass / +22 Partial / 32 Pending. Native proof includes `beta10-settings-phase1-result.json`, +`beta10-settings-phase2-result.json` and `beta10-settings-toast-visual-review.json` in the Oct4 +checklist directory. + +**Phone system-theme compatibility passes (item 51, 2026-10-04).** On the installed beta 10 +Pixel, Computers and Settings native screens remain readable under system dark and light modes, +including status/navigation bars; four captured PNGs have a matching visual-review receipt. +The app retains its fixed dark palette in both modes; an automatic light app palette is not claimed. +Original system night mode `yes` is restored and verified. Tablet/foldable is conditional SKIP +because neither is available. Proof: `beta10-system-theme-result.json`, +`beta10-system-theme-restored.json` and `beta10-system-theme-visual-review.json` in the Oct4 checklist +directory. Item 51 passes, giving the current 10 Pass / 22 Partial / 32 Pending ledger. + +**Final phone cleanup and background watch remain pending (2026-10-04).** The intended Pixel's +wireless ADB endpoint is no longer reachable; its current endpoint has been requested. The guard +refused before the final phone Forget and portrait-lock restoration, so neither action is claimed. +Earlier rotation restoration belongs to the first test round; current final restoration is pending. +The isolated empty SSH service is stopped. Phone name/API and system night mode `yes` restoration +are verified. No natural-periodic background Done watch has started and no result is claimed. +The remaining phone cleanup and notification checks can continue when the correct Pixel returns. + +### What is still open + +**A137 follow-up:** source `b9e4cbc7`, fresh1069/116/offline app and exact-head CI five/ten pass; +retained-signer beta23/code24 is prepared and reviewed. Installation remains unverified after the +pre-install snapshot refusal, with no update dispatch. Once the intended Pixel route is admitted, +perform the guarded update and measure one fresh pending-Send cover/Back case with unique native +execution. The source gap does not establish physical duplication or automatic retry. + +**A136 follow-up:** publication, beta22/code23 update and one no-Send remount comparison pass. +Owned cleanup is verified with the uncertain timeout result preserved and 15000 read back. Verify +selection/IME, activity recreation and pending-Send separately; process-death restoration is unsupported. + +**A135 follow-up:** source publication and beta22 installation pass; measure raw-input rearm and pending-Send Ctrl +on the phone. JVM policy/source pins do not establish real Handler scheduling. + +**Silent SSH peer pilot:** automatic recovery, retained draft text/glyphs and one explicit +Send/application ACK pass through one old-peer pause on shared loopback. Whole-host/network loss, +cursor position and native-route lost replies remain unverified. Separate fresh retained- and +lost-receipt cases pass the newer-draft/disabled-Send boundary, with automatic original-shell +recovery after loss and a unique application ACK/witness over 120 seconds later. Network blackhole, +whole-host loss and remount/Ctrl during pending Send remain next steps. The initial image-preview rendering +interpretation is withdrawn after raw-pixel comparison. See [the bounded case](android.md#silent-ssh-peer-recovery-and-single-send-2026-10-08). + +**Login-shell probe (`A134`):** the source fix, caller controls, exact-revision gates/CI and one +fresh original session-host create/visible Bash prompt are verified. A separate fresh consumer +case now passes production native capture retention and ordinary renderer reload/input with the +original daemon/Bash/producer/socket and untouched sibling. Next cover A129/A130 on the phone +through their supported native route, silent network loss and native-route held/lost-reply races; canvas history +gestures, full rendered payload equality, the V13 child cause and whether fallback fired remain +unverified. Preserve the installed beta21 artifact. + +**Viewer queue follow-up (`A131`):** source is fixed/published in `11fbff08`. Current +`ef4caec2` passes forced 1033/110/app/TypeScript and all-five/ten-step CI; retained-signer beta +21/code 22 is installed. Its bounded 1200-row/40-ms output case continues during backgrounding +and reaches the resumed phone with the confirmed completion marker. Wider lifecycle checks remain +unverified. [A132](android-audit-2026-09.md#a132)'s original Fedora completion loss remains open; +the clean native 2-ms controls, fresh normal-Fedora 40-ms discriminator and later direct-SSH +case do not locate its cause. The normal-Fedora result records all rows and the standalone marker, +with exact owned cleanup; it has no historical prompt parity or final-write/wait-status witness. +[A133](android-audit-2026-09.md#a133) is source-fixed/published at `ef4caec2` after native raw exit +discrimination. Final forced 1033/110/app/TypeScript and CI five/ten pass; beta 21/code 22 is +installed. Its bounded same-page retention, exact final-marker/Unicode clipboard paste and +post-font OSC8/plain link offers pass. The later normal browser/Share pilot records the exact +address-field nonce and selected chooser preview; browser Back remains unverified and ordinary +resume succeeds, while chooser Back returns to the owned terminal. Wider TUI/lifecycle checks remain +unverified. Do not +repeat a successful installation. +The user removed the Pixel 7a's screen lock and authorized ongoing tests; wake it normally without +repeating unlock questions. Already-dispatched WebView commands remain outside the queue fix. +A129/A130 need trusted HTTPS/WSS and real native/session-host relay; direct SSH cannot prove that +overlay. Separate V8/V9 native Desktop pilots stop at empty capture before producer input. +V8's eight-second gate is shorter than supported cold-create budgets. V9's 30-second gate still +has 280 empty captures; two complete ten-thread snapshots observe different empty daemon +generations, not proven original creation or attachment. The earlier passive V13 observation +reaches only the original PATH wait; after the A134 fix, fresh V14 observes its original create +fulfill and Bash prompt without input. Native history/phone acceptance and the V13 child cause +stay unverified. [Scoped receipts](android.md#fresh-fedora-output-and-native-desktop-readiness-2026-10-08) +retain both negatives and exact owned cleanup without product attribution or A129/A130 acceptance. +Controlled direct-SSH recovery, two active Home/resume cycles and explicit Send now pass as +[separately scoped](android.md#direct-ssh-reconnect-and-active-background-continuity-2026-10-08). +Native-route held replies, pending-Send remount/Ctrl races, silent network outages and wider +lifecycle/notification cases remain. + +**Earlier beta-19 publication; current remaining `A129`/`A130` acceptance:** Signed `5bda2c32` is +published with fresh forced 1015/106 protocol, offline app/full TypeScript and all-five/ten-step +Android CI `37608652625`. Retained-signer beta 19/code 20 is installed with actual SDK-36 +verification; SDK 37 is absent. The 6.18-second in-place update preserves first-install time and +notification grant/flags. Public SSH identity continuity and one fresh explicit-profile +authentication/first pin pass through an owned ADB reverse tunnel. Bounded known-tmux Send, +brief background draft retention, viewer/SSH-handler recovery and exact owned cleanup pass; +saved-profile/pin reuse and manual changed-key refusal now pass. A129 native/session-host history, +A130 held Live touch, physical ConPTY and wider lifecycle/notification acceptance remain pending. Preserve the immutable-view/new-intent +mode/ownership contract and known-tmux/direct-SSH gesture route. Coordinate additive page and +invalidation adoption with iOS @eneskirca. See the [current bounded receipts](android.md#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07). + +Normal existing Claude authorization passes both direct and sibling tool-free inference. At +`41406fef`, one original rule passes real Claude/Desktop application; the later `e06b5547` case +also verifies beta 19's original remembered-rule and single/multi question choices against real +CLI application. Rule persistence after CLI restart and production-pane-child launch remain pending. See the [application receipt](android.md#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07). The disposable +API-30 emulator's one provisioning reboot and one scoped reconnect fail to restore live ADB; +console/framebuffer/debugger diagnostics add no app/CA/APK proof. Its exact owned cleanup passes. +Do not restart the stopped fixture or treat those observations as A129 acceptance. The original +10/22/32 ledger, regular Desktop deployment and no-PR status remain unchanged. + +**Backend Send follow-up:** The current source adds negotiated session-host and direct native +Windows Send; physical ConPTY and phone acceptance remain pending. See the +[2026-10-07 checkpoint](android.md#composed-send-backend-follow-up-2026-10-07-a128). + +**Earlier beta-17 acceptance checkpoint:** At that earlier checkpoint, beta 17/code 18 from `a79375c3` was installed; merged forced 982/102 protocol and app +and affected Desktop gates pass, and exact-head Android run `37523703627` has all five jobs/ten +critical steps green. A127's neutral viewer-close/manual-Reattach case and A128's emacs +copy-mode Send are physically verified on the intended Pixel 7a. The controlled vi transport +recovery/Send and single raw Ctrl+C cases pass too; actual managed creation, exact owned SSH End +and final profile/key/host cleanup pass, with the frozen report bound to 303 raw hashes/nine +bounded cases. Next device acceptance needs changed-host-key enforcement, deterministic +held-receipt draft edit/rearm/stale-completion and uncertain-ack cases, other control combinations +and broader lifecycle/Compose/WebView/provider flows where admitted. These existing gaps remain +separate from the completed bounded cases. The [beta-17 checkpoint](android.md#pixel-7a-beta-17-upgrade-and-composed-send-2026-10-06) +records the same-signer saved-profile update and bounded repaired-device cases; earlier beta-16 +Start→Back/Home, process restart, custom dragging/momentum, reconnect, Find and exact-owned +SSH End evidence remains historical. Neither set completes the original checklist. Future +publication changes require their own mandatory gates and all five exact-head Android jobs. +No PR is opened. + +A125 is source-fixed in `415dae9b`; A126 is committed in `5b7286b3`. +The controlled eight-case Linux Desktop reconnect matrix now verifies actual Canvas mounting and +selected-view native keyboard recovery, with retained remote identity/full same-grid history, +zero unexpected input, complete URL activations and exact owned cleanup. 294 affected tests, +full incremental TypeScript, 15 assertion-caught mutants and forced950/98/app compilation pass +on `5b7286b3`. Before any later publication HEAD, rerun the mandatory offline gates; after push, +verify all five Android jobs on that exact HEAD and retain the private CI receipt/final report. + +One later two-host soft/none case at `59e4c93e` adds quiet park/adopt and inactive global-card +reconnect acceptance while active A remains unchanged; see [the bounded two-host follow-up](android.md#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06). Next release acceptance needs the +intended phone for the remaining lifecycle/outage, answer/notification/permission and live-provider +checks, and hosted-backend maintainers for A25/A93. Additional host coverage includes streaming/ +expiry/eviction while parked, repeated/broader outage/backoff, foreign-host attachment, +Server/cross-window responder ownership, GPU/full-frame display and macOS/Windows. Earlier +`11e22453` stale-Modal, unequal-grid refusals and `415dae9b` unclassified exits/18-byte DA failure +retain their own provenance. The paused phone ledger is not promoted by this native matrix. + +Resume the [64-item device checklist](android.md#device-checklist) on the intended available phone +and record Pass/Fail/conditional Skip with model, Android/WebView version, Desktop revision and +route; turn each product failure into a finding. Prioritize lifecycle/reconnect, held answers, +notification permissions/actions and cellular routing. Live-provider checks additionally need +usable provider authorization; the earlier HTTP 401 is not acceptance. A fresh Pixel 7a does not +prove the original phone's same-signer update or key/pin survival. These physical and backend +obligations remain distinct from optional wider host/platform/stress coverage. + +The A86 native regression verifies custom copy-mode gains, chunked distance and reversals in +two tmux key modes. The later Pixel 7a follow-up adds physical held-drag checks with custom +`-N7` bindings in both modes and bounded momentum/touch-stop sampling. FPS, the wider reversal/ +gesture/lifecycle matrix and other platforms remain open. Recheck the phone connection and +focus before the next device action; the original phone's pause remains in effect. + +A124 now passes a physical managed plain-shell creation/registration/input/reopen/app-restart +case on the separate Pixel 7a and frozen Desktop `40731381`. Retained-history Find and clipboard +checks pass too, and both owned QA profiles/hosts are cleaned up. Remaining background/lifecycle, +outage, answer/notification/permission and live-provider checks still need their own device +matrix. The original Pixel 10 Pro beta-10 checklist remains paused and unchanged. + +A92/A121 now pass eight native Linux Desktop UI cases at `01b4a2f5` and eight genuine Linux +Server UI cases at `dcdf664a`, with both native mutants caught on each shell. This local +follow-up is finished. Later historical `972f7994` / built `01b4a2f5` runs also pass the +GPU-enabled eight-case matrix (four hardware Canvas WebGL2, four intentional DOM Modal), native +File-menu Quit, outside-terminal Ctrl+Q and one real tmux Canvas pilot. A122 fixes focused-terminal +native Quit in `9613cec5`; all four Linux native focused cases pass. +Later checks pass all eight tmux and eight actual SSH-project cases, plus separate single-context +GPU loss/restore controls and A123 retirement verification. A genuine Server retirement pilot at +`fa259f6e`, derived Desktop compositor cell comparison at `db83fb7d` and a single Desktop SSH reconnect +pilot also pass. Repeated/page-wide +GPU pressure, full-frame compositor/display output, reconnect scenarios beyond the controlled eight-case +matrix and bounded two-host global-card follow-up above, and macOS/Windows remain unverified. +A120 also passes native timing +and synthetic +installed-Fedora-PAM checks; real-user policy and Android setup/lifecycle remain pending. + +Current source closes the launch/policy/offscreen-wake/history/held-answer/SSH-Git gaps, +and adds explicit SSH profile selection and confirmed one-time password enrollment (A119/A120); +A108 adds owned SSH Board and Desktop node nudges. The original phone checklist remains paused; the separate Pixel 7a results above cover only the named checks. The branch prepares the upstream Android contribution; +no PR has been opened. A25/A93 are maintainer backend dependencies with no source supplied. +A68 is source-fixed for readiness: main-only push, retained PR checks, no merge-group trigger, +and all-five-job opt-in manual beta preparation. +A111 implements host-owned managed New over SSH on current enabled local Linux/macOS tmux hosts; +Windows/non-tmux and third-machine creation remain unsupported. The A13 host follow-up also preserves direct-SSH viewers; eight Linux native checks pass, while physical SSH/phone and other-platform runtime checks remain unverified. + +The user resumed broader Pixel release checks on 2026-10-04 after the hike. Beta 10 / code 11 is +installed with A90/A91 and A94's authoritative-empty completion fix. Item 1 paired-update passes; +A93 fresh-different-desktop refusal is open; A95 is fixed in `ec12ea9a` with actual phone-to-rebuilt +production desktop verification, completing item 36. Phone checks resumed after the earlier +other-app focus pause, then the user paused them explicitly. Final rotation restoration, phone cleanup +and the beta-11 search/live-refresh/Include checks wait until phone testing resumes. +A91/A94 full installed End/recreate/open/End +empty-screen flow passes, preserving the connected SSH route and error. +Focused A90 proof keeps item 32 Partial, giving 10 Pass / 22 Partial / 32 Pending. The otherwise-empty SSH host's last-shell End/empty-list/New-terminal +variant passes on beta 9 Oct4; relay plain-shell creation/input passes, while managed and cellular creation remain pending. Phone checks are paused; the ledger stays unchanged. + +Continue the remaining Pixel 7a device matrix in fresh isolated Desktop/Server fixtures with +A124: background/lifecycle and outage recovery, held answers and notification/permission paths, +then provider-authenticated agent creation. The single managed plain-shell case is verified. +A same-signer beta-16 update and saved pairing/key/pin survival still need the original Pixel 10 Pro; +the fresh Pixel 7a installation supplies no update/migration proof. The +prepared APK includes A118–A120's Android changes. Keep the held older paired +fixture and its original terminal intact. Test A100–A120 and earlier A96–A99 on the intended Pixel; +source/build results do not promote physical checklist items. + +**Next work when phone testing resumes:** continue the remaining lifecycle/outage, answer/notification/permission and live-provider matrix; the A124 single plain-shell case passes. **Original checklist work, in order** (item lists were written for this session's workflows; re-read each audit +section before starting, since the verifier corrections take precedence): + +1. **Finish the remaining relay/session, notification and inset checks.** + Commit `ec12ea9a` fixes core routing and actual Desktop preload delivery; nine suites/133 tests, + full TypeScript checking, strict acceptance-test checking and four mutants pass. The same + owned profile now runs the rebuilt production desktop; actual phone move/remove/re-add/create-label + changes reach its mounted Board with matching persisted state and unchanged page time origin, + completing item 36. Wake, Refresh repaint and the remaining matrix need their own evidence; exact owned phone End passes. + A91/A94 beta 10 End/recreate/open/End completed-empty flow passes; continue the remaining + managed creation, remaining relay routes, Board persistence and notification matrix. A91 first Home/exact last-End/empty-list/New-terminal + passes on beta 9; the second Home shell survives the code10→11 update. Original complete-profile + restart/re-pair restores relay credentials, and saved relay reconnect/input after updating + passes item 1. A93 remains open for a fresh different desktop; iOS recovery needs verification + for @eneskirca without resetting identities or transplanting credentials. Debug migration stays + conditional SKIP. Beta 10 is installed on the intended Pixel; earlier betas verify manual SSH authentication, project listing, + input, font/keyboard resizing and pre-attach tmux history. Historical code-7 proof reopens SSH with the retained + key/pin and verifies continuous drag/coast/Esc. The user confirms final beta-6/code-7 cellular + WireGuard connection and smooth scrolling with Wi-Fi off on regular manual SSH. + Then repeat the verified A90 flow over cellular when authorized, using Sessions → New terminal → Home/project/custom absolute folder → Create: + real cwd/input/history, disconnect/app-restart rediscovery and End of only the owned session, + with existing desktop sessions/files/canvas unchanged (item 32). These are independent shells, + without managed-agent/canvas registration. Check needed answers, actual airplane/outage recovery and relay/ + notification behavior. Ten complete items now pass (1/18/19/21/22/24/36/38/39/51); the 64-row table records + remaining Partial/Pending items and conditional SKIP variants. Turn every actual failure into a + finding. Paste pairing/hosted relay browse/input and synthetic shipped-hook approval lifecycle + now work; mounted relay questions add partial proof. QR/cellular-relay/SAS/remaining question + variants and the full checklist remain open. Future + private betas retain the same signer and increase the version code above the installed beta. +2. **Finish remaining scroll matrix (`A86`, `A89`).** Code 7's historical SSH reopening with + retained key/pin, continuous dragging, coast and Esc pass + on the intended Pixel, and the user confirms normal drag/coast both work. Real SSH background/ + detach recovery and final cellular WireGuard SSH connection and smooth scrolling are also verified; finish reversal + and remaining lifecycle checks. + The dimension-stable 56×25 held-touch check also passes; + earlier keyboard-resizing touch checks were inconclusive. Beta 5 + stopped mid-swipe; `c4b1f6cf` targets the stable screen and its bundle regression covers real + span removal versus continued drag/release. Historical beta-6 protocol/type-check checks pass, with + 35 JS, eleven native and three CSS mutations caught; custom wheel bindings/FPS remain open. + `A88` now reproduces and fixes + SDK 37's actual scheme label, with 39 real-tool tests per SDK 36/37 and ten new mutations. + CI4 still failed private packaging; follow-up [run `37061593216`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37061593216) + at `c37798b6495b4b68df379d0ae80887c23104b66d` completed with all five jobs green, confirming + observed CI repair. Each subsequent push still needs its own green workflow. + Push is authorized: run required checks before each push and confirm Android CI afterward. +3. **Verify the new source flows and remaining known gaps.** Batch D/E and A103/A104 source + changes are done. Remaining physical/live cases include relay cellular/SAS-denial/revoke, + offscreen Sleeping wake and non-Claude permission policy. Verify retained history, structured + hook answers, SSH Git, the selected-profile Board/node service and A111 managed SSH New too. Read-ack ownership is + locally fixed in `A82`; finish its device verification. + For iOS adoption, @eneskirca should read `relayApproved` and `sshHostKeyFingerprints` in the sealed + `/pair` answer and `lan` beside `projects.list` output; iOS also needs to send `boxPublicKey` + there for late-adoption approval and the revoke cut, and should key notification seen state + by computer. Adopt the new held rule/question fields, verbs and v2 SSH marker together; + `docs/hook-reply-approvals.md` lists the exact iOS changes. +4. **`A68` source readiness.** Android uses `push: branches: [main]` plus the retained + `pull_request` path filters, with no `merge_group` (see the verifier). Branch pushes no longer + trigger duplicate Android runs or beta preparation. Available manual runs with + `prepare_beta=true` run all five jobs on the selected ref. Keep exact-head green CI before + declaring the branch ready; PR creation remains separate from this checkpoint. A CI release + path requires APK/R8/provenance from the same successful run. + +**Known gaps and caveats:** + +- **A13 coattachment is source-fixed in `ed2965be`.** Current hosts preserve external SSH/relay clients and selectively retire only verified same-profile app painters. Real tmux control-client checks and eight Linux native checks pass; physical SSH/phone and macOS/Windows runtime checks remain pending. Android retains exit-zero reattach for older hosts. +- **A56/A57 are source-fixed through v2 request-owned hook replies (A105/A106).** Bounded + phone/real-CLI rule and question cases pass; restart persistence/wider scope remain open. Legacy unheld multi-select + still opens the session. iOS adoption is owed to @eneskirca; see `hook-reply-approvals.md`. +- **A49 / A74 residuals.** Phones paired before batch E have no anchors and stay on + trust-on-first-use until they pair again or get a relay listing. Recursive Includes are source-fixed in A99. A relative `HostKey` path remains unread, so SSH is refused for + it (Auto falls back to the relay). A116 adds a Desktop network selector shared by the QR and + authenticated relay address refresh; an unavailable chosen adapter never silently falls back. + Actual multiadapter/VPN checks remain pending. A phone that only uses "Only on my network" + never gets a relay address refresh. +- **A72 follow-up is source-fixed (A102/A103).** Trusted project env/shell is prepared before + cold relay input; saved owner/account/agent wins. Per-agent launch/resume/wake policy uses actual + host capabilities. Physical/live agent policy combinations remain open. +- **Quick-answer false-success hazards found during batch E.** Local desktop input previously + used a prefix-matching target and did not cancel copy mode; direct-SSH phone input targeted + exactly but did not cancel copy mode. Those continuation hazards are locally fixed in `A78` + and `A79`: both paths pin the resolved pane ID and cancel copy mode before sending, desktop + deliveries are ordered per node, and an unconfirmed operation never reports success. Full + protocol/type-check checks pass; phone verification of those quick-answer cases remains open. +- **A33 on an older desktop.** An older desktop ignores the new attach fields, so a Windows host + still starts phone sessions in the home folder until it is updated. +- **A07 for older pairings.** A device paired without a recorded `relayBoxKey` (before A07, or + any phone that does not send `boxPublicKey`, which includes current iOS builds) has nothing to + unpin or cut on revoke and is not approved on a late adoption. A113 now reports that relay + revocation is unconfirmed, rather than promising remote access is gone. A118 now repairs an + eligible association through approved relay plus exact retained SSH proof. It cannot recover + missing SSH seeds/attribution or bypass first SAS approval; those cases still need re-pairing. +- **Remaining parity.** Current typed SSH Git and the A108 selected-profile service support + Board writes and Desktop delivery-only node nudges without relay. Git works on admitted + local/driven folders independently of the service; Server advertises Board and, with an + enabled local POSIX tmux backend, managed New. Renderer node nudges remain Desktop-only. A manual + SSH host has no relay fallback when a service capability is missing. The service admits only + selected-profile ownership, including Desktop's own SSH-project metadata, never another + desktop's driven projects. Managed canvas New is implemented in A111 for a current local + POSIX tmux profile; Windows/non-tmux and another desktop's driven projects remain unsupported. + Phone-owned plain SSH New remains separate, with historical focused Pixel proof. + No immediate FCM or Live-Activity equivalent (A25); A93 needs the hosted relay maintainers. + All-computers lifecycle/notifications and new source flows need physical verification. A114 adds + a searchable dictation language picker; native language/model checks remain pending. SSH setup + has controlled native OpenSSH enrollment/key-login evidence; Android UX, ordinary PAM accounts and macOS remain unverified. It + offers public-key Copy/Share and an idempotent authorized_keys install command (A27). A120 adds optional one-time password setup after explicit host fingerprint comparison, with fresh + retained-key verification before saving. Keyboard-interactive/MFA and the separate Server installer + remain unsupported. +- **Desktop file-link follow-up (`A92`) is fixed in `127b6b28`.** `paragraphContaining` now reserves + the hovered row within the 32-row window. Focused/affected Vitest suites, full TypeScript check + and three isolated mutants pass; eight native Linux Desktop cases now pass at `01b4a2f5` + with A121's leave fix. Separate eight-case Linux Server checks pass at `dcdf664a`; + The later GPU-enabled eight-case matrix passes (Canvas4 hardware / Modal4 DOM). Separate + single-terminal Desktop and genuine Server GPU recovery/retirement pilots now pass; broader GPU + pressure and macOS/Windows remain unverified. + Android already fixed this in `A32`; the held older paired fixture and shared `out/` remain + unchanged. The four fresh `01b4a2f5` builds are separate disposable verification fixtures. +- **A10/A50 trade-off.** The debug key is public by the user's decision. The private-beta packager + rejects it; a private signer is supplied locally and must be retained for updates. Moving from + debug to private beta requires a deliberate uninstall/re-pair once. The first retained private + key is retained in ignored local state; the first actual private beta is built, signed and + updated on the intended Pixel with manual SSH registration/key/pin and notification grant + preserved. The full phone pass remains open; the wrong-MI8 test install was removed. +- **Host record retirement is source-fixed (A115).** Replacement retires route/approval/creation preferences and tokens. Session admission and local record publication share a barrier; stale dials and callbacks cannot overwrite a new pairing. Physical replacement/background checks remain pending. +- **Earlier restricted-sandbox limits:** server-e2e and native-module vitest suites could not run there (`npm ci + --ignore-scripts` skips the native builds, and there is no `ssh` client); the same 14 tests and 31 + files fail identically on the pre-session commit. Desktop CI does not run on branch pushes here, + so those earlier desktop changes were checked by targeted vitest files + `npm run typecheck` only. + Restored-host full desktop checks now pass; see the newest progress entry. + +### WP2 (blockers with decisions): done on the branch, not device-verified + +| Finding | Commit | What changed | Checked by | +|---|---|---|---| +| A05 / A17 / A23 / A30 | `3d36d60` | `RelayApprovalGate` (protocol): the background worker dials the relay only for a computer where a relay connect has succeeded (persisted `relayApproved.`), and then with `requireApproved`, so a revoked pin gives up instead of waiting and clears the flag. A refused (Deny) or unanswered approval holds automatic dials until the user taps Try again / Refresh or opens the computer. `RelayConnector` reports typed `RelayApprovalRefused/Timeout/RequiredException`. | `RelayApprovalGateTest`; relay interop through the desktop's real host session: Deny reads as a refusal (fixture gained a reject mode), and a `requireApproved` dial fails fast without showing a code. | +| A07 | `0fa0646` | Option (b), additive. The phone sends its box key inside the SEALED `/pair` body; the desktop pins it (same `updateApprovedDevices`/`pinDevice` a SAS approval writes) when the relay leg was minted, answers `relayPinned: true`, records `relayBoxKey` on the device, and unpins it on revoke unless another pairing of the same phone remains. A plaintext body never pins. PairScreen says "Remote access is on" and tells the user after pairing whether one approval is still owed. | vitest `pairing-service.test.ts` (5 new cases); pairing interop through the real `createPairingService`. | +| A08 | `1cdd2f0` | Chose "refuse and route to the relay". SSH attach runs `attach-session` after a `has-session` check (exit 3), never `new-session`; a session that is not running raises `NeedsRelayException`, and the terminal offers "Open through the relay" (`HostSession.viaRelay`, a relay connection held next to the SSH one), where the desktop creates it with its hook env. | SSH transport tests: nothing is created by the transport or by the script itself. | +| A09 / A28 | `726271a` (desktop), `1cdd2f0` (phone) | **Desktop:** the relay `pty.attach` of an SSH-project node now attaches over that project's ControlMaster (`requireRemote`, host-side freshness and snapshot) or refuses with the host's name — never locally. **Phone:** over direct SSH, attach/keys/approvals/kill for those nodes raise `NeedsRelayException` and read-acks are skipped; the terminal, the Inbox and End session retry through the relay. | vitest `remote-security.test.ts` (4 new cases); SSH transport test for the refusals. | + +The original A07 follow-ups (`adoptRelayIfAdvertised` and the live-session revoke cut) landed in batch E: late relay adoption approves a recorded box key, +and revoking a paired phone cuts its open relay session. Older pairings without a recorded key +still need foreground SAS approval. iOS can adopt `boxPublicKey`/`relayPinned` unchanged. + +### WP1 (blockers): done on the branch, not device-verified + +| Finding | Commit | What changed | Checked by | +|---|---|---|---| +| A01 / A04 | `af1f820` | `SshStream` owns one single-thread writer for write/resize/scroll; a non-IO exception out of sshj's write path tears the transport down and fires `onClosed` instead of being swallowed. `SshHostConnection.close()` and `HostSession.disconnect()` never touch the socket on the caller's thread, and a failed `SSH_MSG_DISCONNECT` still closes the socket. | `SshTransportTest`: a socket factory that throws when used from a thread marked "main" (a JVM stand-in for StrictMode) drives resize/write/close from that thread; a poisoned transport must surface as `onClosed`. Mutation-checked. | +| A02 | `e7c22eb` | Prelude probes `…/node-terminal` first, `…/nodeterm` as a legacy fallback. A listing with no userData dir is now an explicit error, not an empty computer. `scripts/uninstall.sh` + `docs/uninstall.md` remove `node-terminal` (and legacy `nodeterm`) data, caches, logs and either Keychain spelling. | Fixture uses the real name; new `SshScriptsTest` runs the prelude under `/bin/sh` on Linux, macOS, XDG, legacy and both-present HOMEs. | +| A03 | `cd69a1e` | Attach script exports a UTF-8 LANG by the desktop's `resolveLocaleLang` rule (`en_US.UTF-8` on macOS, `C.UTF-8` elsewhere) and passes `-u`. | Harness no longer leaks the JVM's LANG; `╭é` from ASCII input must arrive intact. Mutation-checked. | +| A06 / A35 | `16706f4` | Desktop writers check `.json` before writing (`fs.access`; `[ -f … ] \|\| exit 3` in the same SSH command) and return `sent \| gone \| failed`; only `sent` emits the synthetic answered event. `approvals.answer` adds `reason` (additive). Phone: `ApprovalOutcome.GONE`; if the node is still blocked, `QuickActions` returns `EXPIRED`, which opens the session with an explanation. | vitest (pending-approvals, ssh-project incl. the remote command under a real `/bin/sh`, host inbox verbs); SSH transport test; relay interop through the desktop's real verb. | +| A10 | `fcda932` | `android/app/debug.keystore` committed and wired as the debug signing config. **Departs from the verifier's advice** (it warned that a public key lets anyone sign an update that inherits the app's data); the user chose it for sideloading. The trade-off is written in `build.gradle.kts` and both READMEs. | CI build (AGP cannot run in the sandbox). | + +The iOS-facing part of A06: `approvals.answer` now replies `{answered:false, reason:"gone"|"failed"}` +when it did not deliver. iOS can adopt `reason` unchanged; an older phone keeps reading `answered`. + +## Where things are + +| | | +|---|---| +| Repo / branch | `CPlusPlus17/nodeterm`, branch `claude/android-ios-parity-75kfem` (pushed) | +| Commits | `a0c07e6` protocol module + the two desktop relay verbs; `2f58918` the Compose app, docs, CI, desktop copy; `2dd539f` this handover; then every fix and feature in the progress log, newest first (`git log` on the branch) | +| PR | none (do not open one unless asked) | +| CI | `.github/workflows/android.yml`: **Protocol**, **App** (`assembleDebug`), **App release** (R8, unsigned), **CodeQL (Kotlin)** and **Private beta checks**. Android-relevant `main` pushes and pull requests retain their filters; takeover-branch pushes do not trigger Android. Use `workflow_dispatch` with `prepare_beta=true` on the exact published HEAD for all-five-job feature-branch verification and CI beta inputs. Each requested push still requires green exact-head Android verification. | + +What is in the tree: + +- `android/protocol` is pure Kotlin/JVM with no Android dependency. It holds the NaCl port, the relay + client, the host RPC, pairing, direct SSH over sshj, the parsers, and the pure rules the app's + screens use (most app logic lives here so it can be tested). The remote branch had 589 tests + before these continuation additions; full forced offline checks at `2a6e005b` pass **982 methods / + 102 suites**, with zero failures, errors or skips. Its JVM tests + include desktop interop and SSH harnesses. The + interop tests run the desktop's own `connectHostSession` / `createPairingService` through + `android/protocol/src/test/interop/host-fixture.ts`; the SSH tests use Apache MINA sshd with a + sandboxed tmux. +- `android/app` is the Compose app: pairing, hosts, the Sessions/Board/Inbox/Usage tabs, the + terminal (xterm.js in a WebView), settings, and WorkManager notifications. +- `android/tools/typecheck` is an offline Kotlin type-check of the app for sandboxes without Google + Maven. See its README for what it can and cannot catch. +- Desktop side: + - `src/main/remote/host-service.ts` gained the relay verbs **`approvals.answer`** and + **`inbox.ack`**, tested in `src/main/remote/host-inbox-verbs.test.ts`. + - `src/main/index.ts` extracted `answerPermission` and wired `hostBridge.inbox`. + - Later additive changes the phone uses: `node.sendKeys` (`A12`, SSH-project nodes over their + ControlMaster since batch E), the `projectId`/`accountId`/`agentId` fields on `pty.attach` + (`A33`/`A72`), `relayPinned`, `relayApproved` and `sshHostKeyFingerprints` in the sealed `/pair` + answer (`A07`, `A49`), `lan` beside the `projects.list` output (`A74`), additive account + `label`/`email` in the mirror (`A39`), the `-D`-free attach while a relay phone is attached + (`A13`), the hibernated self-heal in the mirror (`A76`), and the revoke cut (`A07`). + - The renderer copy now names both phone apps (`PhonePairPopover.tsx`, `PhoneSection.tsx`, + `lib/links.ts`). + - CLAUDE.md gained an Android paragraph under Conventions → three surfaces, and CONTRIBUTING.md + and README.md gained notes. + +**A111 — managed New over direct SSH (2026-10-04).** The host creates a genuine local tmux +pane through normal PTY preparation, registers canonical project/local execution facts, and +submits its own planned agent command once. Request UUIDs and public attachment receipts persist +on the phone before dispatch and until an actual SSH viewer is confirmed. The private host phase +journal has file/directory sync barriers; after uncertainty/restart it permits inspection, never +blind replay. The shared recovery marker contains only an inert notice, not an expanded command. +Managed canvas End still needs the relay; closing an SSH viewer leaves it running. +System account selection stays explicit; removed/pending/remote accounts and changed folders are +refused at spawn. Current runtime, kernel process birth and selected-profile service identity are +required for the first attach. Non-tmux/Windows and third-machine creation remain unsupported. +Actual isolated native-PTY/tmux creation with a fixture agent passes; live CLI and Pixel checks +remain open. Companion interop, mutation checks and beta-13 local release gates pass. iOS adoption +of the new advertised action and receipt is owed to @eneskirca. + +## What is verified, and what is not + +A137's source fix keeps exact attempt/notice identities through retained-entry remount while +preserving original positive-clearing fences. Local66/9, full1069/116, offline app and thirteen +intended mutants pass. Published source `b9e4cbc7` also passes fresh1069/116/offline app and CI five/ten; +retained-signer beta23/code24 passes its 46 available artifact checks. Installation and the controlled +phone case remain unverified: the pre-install snapshot refused before any update dispatch. Beta22 is +last verified installed; A136's no-Send pass provides no pending-Send credit. +See [canonical scope](android.md#pending-send-outcome-after-remount-2026-10-08-a137). + +A136 original-beta loss stays historical. Source/policy/mutation and beta22 installation pass; +one fresh no-Send PairHost/Back retains the exact draft/checked Ctrl. Original native controls +stay equal except the qualified phone-client reattach. Owned cleanup and read-only 15000 timeout +reconciliation pass; cursor/IME, activity recreation and pending-Send remain unverified. See +[canonical evidence](android.md#retained-terminal-editor-2026-10-08-a136). + +A135 source/policy/mutation, exact source gates/CI and beta22 installation pass; physical +raw-input timing remains pending. See +[canonical evidence](android.md#captured-ctrl-consumption-2026-10-08-a135). + +Verified: + +- Fresh beta21/direct-SSH lost successful Enter receipt: exact newer draft and disabled Send + before loss, retained draft through automatic original-shell recovery, one original-TTY + witness/ACK still unique over 120 seconds later and owned cleanup. Android internal status, + blackhole, whole-host loss and native-route loss remain separate; see + [the measured receipt loss](android.md#lost-send-reply-and-newer-draft-2026-10-08). + +- Fresh beta21/direct-SSH successful-receipt hold: exact newer draft and disabled Send before + release, the same draft retained/enabled afterward, one original-TTY witness/ACK still unique + 168.735781 seconds later, unchanged original terminal/guard and owned cleanup. At that checkpoint, + lost reply, blackhole, remount/Ctrl and native TLS/history were unverified; see + [the measured receipt hold](android.md#held-send-reply-and-newer-draft-2026-10-08). + +- `A131` final focused control/restored passes 71 methods/8 suites, zero failures/errors/skips: + 11 new behavioral queue/page methods, three app source pins and existing lifecycle/input tests. + Ten unchanged queue/page behavioral mutants and four final app source-pin mutants fail assertions. + Fresh full publication and beta-20 installation pass; bounded background/output checks are partial + with A132/A133 open. No physical queue race is claimed. See [the scoped checkpoint](android.md#viewer-output-lifetime-source-checks-2026-10-07-a131). + +- Exact merged `a79375c3` executes full forced 982/102 with zero failures/errors/skips plus app + Kotlin, affected 187/11 and full TypeScript; Android run `37523703627` has all five jobs/ten critical + steps green. Retained-signer beta 17/code18 is installed on the Pixel 7a, preserving the single + saved temporary SSH profile/first-install time without pin edit/key readmission. Bounded emacs + composed-Send, vi transport recovery, raw Ctrl and A127 viewer-close/manual-Reattach pass; + actual managed creation, exact owned End and cleanup/report pass too. The wider physical matrix + remains pending; the original Pixel 10 Pro 10/22/32 ledger is unchanged. + +- `A91` clears only an authoritative empty SSH listing, retaining uncertain cached listings and + propagating cancellation without changing route/error/connection policy. Four new regressions, + full protocol 688/67, offline app compile and six new isolated mutants pass. Historical beta9 + delivery and its Oct4 first Home/exact last-End/row-and-group removal/connected SSH/second Home + creation pass. That second shell was retained for the beta 10 update; the owned SSH pane survived and exact sole-End now shows completed empty, no + Loading/row/group, with SSH/error retained. Home recreation opens a new actual bash pane + and its exact End restores completed empty. Final fixture/service cleanup is pending. +- `A90` plain SSH shells are implemented in `bcc92367` / `b88d1415` and host-verified by the full + 684-test/66-suite protocol run, offline app compile and 32 new behavioral/wiring mutations. + Atomic interrupted-creation rediscovery, frozen retry, fingerprint guards, dedicated sockets, + reconnect/history/End and relay phantom refusal are covered. Beta10/code11 is installed; + its AGP/R8/signing/alignment/provenance and independent SDK 36/37 review pass. Focused real + Pixel Home/project/custom creation/history/restart/update/reconnect and exact End promote + item 32 to Partial; item 1 paired update and later item 36 Board verification give the current 10 Pass / 22 Partial / 32 Pending. + Relay plain-shell creation/input/End pass; managed and cellular creation remain pending. + The A91 otherwise-empty-host variant passes on Oct4. +- The protocol against the desktop's real code: the relay handshake, SAS, approval wait (and the + host's decision hold), `projects.list` with its `lan` report, attach/snapshot/input/resize/exit, + scroll, node actions (quick answers into SSH-project nodes through a fake remote writer), board + verbs, `git.*` through the real `GitService`, `approvals.answer`, `inbox.ack`, registration, and + pairing (E2EE `/pair`, the key landing in `authorized_keys`, the relay pin, the SSH host key + fingerprints from a fixture key directory). +- Relay security properties: no re-key, reflection, replay/reorder, and a foreign key. +- SSH transport against a real SSH server with tmux, including the pin anchors, a host + certificate, and the Server Edition / driven-host browse. +- The remote `send-keys` line the desktop generates for SSH-project nodes, under a real `/bin/sh` + and tmux (not over a real ControlMaster). +- The debug APK builds with AGP in historical CI runs. The installed private AGP 8.10.1 release + built locally, passed R8 keeps, and runs on the intended Pixel with real direct-SSH browse/input + proof. The wrong-MI8 test installation and SSH key were removed. See the newest progress entries. +- The historical beta-6 correction passes 658 protocol tests in 63 suites, zero failures/errors/skips + (67 seconds), and offline app type-check (8 seconds). Thirty-five JS, eleven native and three + CSS mutations are caught. Code-7 AGP/R8/signing/same-signer update passes, preserving app data; + notification permission is confirmed. SSH reopened with its retained key/pin at 56×48, and + controlled continuous drag/coast/Esc checks pass. A held touch stops coast at a stable 56×25 + viewport. Historical beta-4 controlled movement/reversal/Esc passed; the user confirmed more movement but missing + momentum. `A88` packaging passes 39 Python tests against each real SDK 36/37 and ten new + parser/fixture-selection mutations; [run `37061593216`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37061593216) + at `c37798b6495b4b68df379d0ae80887c23104b66d` completed with all five jobs green, confirming + observed workflow repair. The user confirms normal continuous dragging and coast both work. + +- Requirement-audited real Pixel QA completes checklist 18/19/21/22/24/38/39 (SSH key/input/font/ + rotation survival, Unicode, large/invalid OSC52, + keyboard-focus states, process-kill tab/back-stack restoration and synthetic shipped-hook + Approve/Deny/expiry). Copy sheet, + links and SSH background/detach recovery have additional partial evidence; the 64-row table + records exact scope. Those results remain historical beta-6/code-7 proof; beta 10 / code 11 is now + installed. Recorded prior all-green branch `19da35a2` passed all five CI jobs in run `37140762345`; + historical beta9 APK source `4d33a5b5` failed its documentation checklist mapping in run `37144282865`; + current beta 10 source `e3ce041c` has all five jobs green in run `37194375778`. + Desktop-issued beta9 pairing/relay credentials survive the code10→11 update and saved relay + reconnect/input. A93 fresh-different-desktop refusal remains open; A91/A94 full focused physical cycle passes. Each later push still requires + its own green workflow. +- Encrypted paste pairing and forced relay-only browse on the real Pixel succeed against the + actual hosted backend and production desktop source `58a202be` in an isolated private home. + The current `{deviceToken, hostId}` join request and accepted reply are verified beyond interop. + The approved-at-pairing path expects no SAS dialog; QR, cellular relay and denial/revoke variants + remain device checks. +- Actual relay terminal attach/echo input and item-39 held-hook lifecycle pass on the owned + production-preload/Canvas node. No phone New session, live Claude CLI/account or requested Bash + execution was involved. Approve/Deny return actual hook JSON within 45 seconds; expiry returns + empty and the phone reports timeout/opens the terminal. At that stage protocol/offline app checks passed + in 54/5 seconds. +- Mounted relay single-select, multi-select Open-session/input and copy-mode answers reach the + actual synthetic application and shipped PostToolUse/mirror. Item 41 stays Partial; remaining + transport/target device variants stay open. Only disposable fixture resources were cleaned up, + and the phone returned to regular Sessions; the final cellular WireGuard SSH check is now + user-confirmed: "Connects and scrolls smoothly" in beta 6 / code 7. + +**Not verified:** + +- **Remaining phone behavior.** The intended Pixel has beta 10 / code 11. Its same-signer installation, + install identity, notification permission, owned SSH pane and saved desktop-issued pairing/relay + reconnect/input survive the update. A91/A94 full installed End/recreate/open/End empty-screen flow passes. Focused A90 Home/project/custom creation/history/restart/update/reconnect and + exact End pass; beta 10 relay plain-shell creation/input/End also pass, while managed and cellular creation remain pending. A91 empty-host flow passes on Oct4. + Historical code-7 controlled continuous drag/coast/Esc are verified. Earlier pre-attach history and Wi-Fi-off + mobile-data WireGuard access are verified. Historical beta-6 real SSH background/detach recovery and + process-kill restoration, paste pairing, hosted relay browse/input and the synthetic shipped-hook + approval lifecycle are verified; QR, cellular relay, SAS denial/revoke, actual outage/notification/ + remaining question/copy-mode routes/target variants and the full 64-item checklist remain + unverified. Beta 4 lacked momentum; beta 5 lost + continuous touch events. `A89` corrects that target lifetime. New-touch stopping passes with a + held touch at a stable 56×25 viewport; earlier keyboard-resizing checks were inconclusive. + The user confirms normal drag/coast both work. Reversal/remaining lifecycle checks, custom wheel + bindings and FPS remain open under `A86`; the final beta-6 manual-SSH cellular WireGuard + connection and smooth scrolling check is user-confirmed, separate from untested cellular hosted relay. +- Release/minified builds on a device. R8 runs for release in CI (`A37`: `assembleRelease` plus + `tools/check-r8-output.sh`). The first private minified APK is signed and installed on the intended + Pixel; direct-SSH crypto/browse/basic input and code-3 `A85` sizing/history work. Remaining + relay action/cellular/SAS variants and worker behavior stay open. +- **Interop coverage:** relay `projects.list` and mirror use actual producers; session-list and + settings-provider inputs remain fixture data. Server-profile SSH + cases also read actual config/platform, workspace and mirror publications through private SSH; + they do not boot a full Server or install hooks/account probes. SSH actions/managed creation and + Android `.seen` files have producer/consumer interop too, with their fixture boundaries named. + Two new relay-advertisement methods exercise the actual account-level writer/remover and Kotlin + SSH parser through a private fixture-only OS-home adapter. Legacy/adversarial browse, held files + and driven-status-slice cases remain hand-maintained. Advertisement shape coverage does not prove + standing-host publication, token mint, adoption, SAS approval or revoke. + +## Environment notes for a cloud session + +These restrictions describe the earlier cloud sandbox. Host access has since been restored, a +phone is available, and the user authorized a local AGP release build instead of waiting for CI. +Use the [local private-beta procedure](../android/README.md#private-beta) on that host; the remaining +notes still apply to a restricted cloud session. Current local build/test results are recorded in +the progress log, and do not establish any phone result until the checklist is run. + +- **Google Maven is blocked** (`dl.google.com` / `maven.google.com` answer 403 from the egress + proxy). AGP cannot resolve, so `./gradlew :app:assembleDebug` does not work in the sandbox. Do not + retry it or route around it. Instead: + - Type-check the app with `cd android/tools/typecheck && gradle compileKotlin`. It passed on the + handover commit. + - Rely on CI for the real AGP build: push, then read the Android workflow run. +- Maven Central sometimes answers 429. Retrying works. +- System `gradle` is 8.14.3 on JDK 21 in the sandbox; the wrapper pins 8.14.3 and CI uses JDK 17. +- The protocol tests need the repo's `node_modules` (an existing `npm install` is enough; on a + machine without the native toolchain use `npm ci --ignore-scripts`, as CI does — it replaces + `node_modules`, so a desktop checkout then needs `npm install` or `npm run rebuild`) and a `tmux` + on PATH. Run them with + `cd android/protocol && gradle test --offline`, or `cd android && ./gradlew -p protocol test`. + Without node the interop tests skip. +- Desktop checks for any change to `src/main/remote/*`: + - `npx vitest run src/main/remote/host-inbox-verbs.test.ts` + - `npm run typecheck` +- Test-writing traps already hit once: + - A JUnit 5 test whose body returns non-Unit is silently skipped. Use `runBlocking { … }`. + - In the SSH test harness, closing stdin on a pty command sends ^D and kills tmux. + - An exact-match tmux **pane** target is `=name:`, not `=name`. tmux 3.4 answers "can't find + pane". + +## The findings in one page + +77 findings survived verification, and none were refuted. Merging the duplicates (`A01`=`A04`, +`A05`=`A17`=`A23`, `A09`=`A28`, `A11`=`A19`) leaves 72 distinct items. The severity counts below are +over all 77: 8 high and 2 medium release blockers, 26 other medium, and 41 low. The work packages +below use the audit IDs. + +### WP1: unambiguous blockers (small, do first) + +| ID | Problem | Fix direction | +|---|---|---| +| A01 / A04 | `SshStream.write/resize` do socket I/O on the **main thread**. That throws `NetworkOnMainThreadException`, which `runCatching` swallows after sshj has advanced its cipher state, so the next packet kills the connection. Keyboard open, rotation, A−/A+ and the key chips all trigger it. `SshHostConnection.close()` from click handlers (`HostsScreen` Forget, `SettingsScreen` route change, `PairScreen`) leaks the socket the same way. | Give `SshStream` one single-thread executor. Route write, resize and scroll through it: this keeps ordering between main-thread chips and JavaBridge-thread input. Run `close()` / `client.disconnect()` off the main thread (`Dispatchers.IO`). Catch `RuntimeException` by closing the transport, not by swallowing it. | +| A02 | The SSH prelude looks for the desktop's data in `…/nodeterm`, but the desktop's userData is **`…/node-terminal`** (package `name`, no top-level `productName`). Confirmed: `src/core/agents/hook-endpoint-failover-sh.ts:79-80` uses `node-terminal`. | Probe `node-terminal` first and keep `nodeterm` as a fallback, in `SshScripts.kt:38` and its KDoc at :18-19. Fix the `SshTransportTest` fixture (:154) to the real name. The same wrong path is in `docs/uninstall.md:24` and `scripts/uninstall.sh:64` on the desktop side; fix those too. | +| A03 | The SSH tmux client starts without a UTF-8 locale, so ╭, accents, CJK and emoji come out as `_`. | In the attach script, export `LANG=en_US.UTF-8` unless LC_ALL/LC_CTYPE/LANG already says UTF-8 (the rule of `resolveLocaleLang` in `pty-manager.ts`). Add `-u`. Add a test with LANG unset. | +| A06 (+A35) | **Desktop:** `approvals.answer` writes the answer even after the hook's 45 s hold ended, then reports success and emits the synthetic answered event, which clears NEEDS YOU everywhere. The canvas button has the same pre-existing gap. `answered:false` cannot tell "gone" from "write failed". | In `answerPermission` (`src/main/index.ts`), check that `.json` still exists before writing (`fs.access` locally, `test -f` in the same remote command over SSH). Return false and skip the synthetic event when it is gone. Reply with a reason (`gone` / `failed`). On the phone, map `gone` for a still-blocked node to "open the session". | +| A10 | CI debug APKs are signed with a fresh debug key each run, so updating means uninstalling, which wipes pairings. | Commit a debug keystore (`android/.gitignore` already has `!debug.keystore`), point `signingConfigs.debug` at it, and say in the README that it is a public debug key. | + +### WP2: blockers that need a decision (recommendations in bold) + +| ID | Problem | Options | +|---|---|---| +| A05 / A17 / A23 (+A30) | The background `InboxWorker` can make the phone's **first** relay handshake, which raises the desktop's SAS approval dialog while the phone shows nothing. It repeats every 15 min, and pressing Deny is not respected: the phone re-dials about 8 s later. | **Add a per-host "relay approved" flag, set only after a foreground relay connect succeeds. The worker never enters the relay leg until it is set.** After "Awaiting approval" or a refusal, back off and stop re-dialing until the user acts. (Aborting on AwaitingApproval does NOT help: the desktop raises the dialog at handshake completion, `standing-host.ts:256-261`.) | +| A07 | Pairing never pins the phone's relay (box) key, and Auto prefers SSH on the LAN. So the one-time relay approval never happens while the user is at the desk, and the first remote connect waits 5 min and fails. The desktop design has the same gap, so iOS likely shares it (`standing-host.ts:424-434`). | **(b) Send the phone's persistent box public key inside the sealed `/pair` body and have `pairing-service.ts` pin it (the standing host's `isPinned` store) when pairing succeeds.** This is additive: an older desktop ignores the field. Raise it for iOS with @eneskirca. Interim alternative (a): right after pairing, run one relay handshake on the LAN and show the SAS on PairScreen. Either way, PairScreen must stop saying "✓ Reachable from anywhere" before approval. | +| A08 (+A72) | A cold attach over direct SSH (`new-session -A` with no `-e` env) creates the desktop's tmux session **without the hook env**. An agent resumed there never reports status, the desktop never repairs it, and if the desktop's client started the tmux server the session inherits **another node's** `NODETERM_NODE_ID` (misattribution). | **Do not cold-create over SSH. When `hasSession` says no, offer "Open on your computer" and use the relay when available, where `attachDetached` injects the env.** The alternative is to pass the full hook env with `-e` (NODETERM_NODE_ID, NODETERM_HOOK_ENDPOINT, NODETERM_HOOK_VERSION, …); that duplicates desktop logic and drifts. | +| A09 / A28 | Nodes of the desktop's **SSH projects** live on a third machine, but over direct SSH the phone attaches to the desktop's LOCAL tmux. That creates an empty phantom session and offers to resume on the wrong machine. | **Over direct SSH, don't attach nodes whose project is an ssh-ref. Show them with "open via remote access" and use the relay.** Check first how the desktop's `pty.attach` handles a remote node. | + +### WP3: device test pass (after WP1 + WP2) + +Install the CI APK (from the new stable debug key) on a real phone and walk the numbered checklist in +[`android.md` → Device checklist](android.md#device-checklist) (64 items, each naming the finding it +checks). +Record results in `docs/android.md` → "What is verified". Anything that fails becomes a new finding. + +### WP4: medium bugs (after the device pass) + +`A11`/`A19` notification tap ignores the target computer · `A12` relay `sendKeys` into a pty that +does not exist yet · `A13` registering while attached lets the desktop attach with `-D` and detach +the phone · `A14` inline (cwd-less) project sessions never registered · `A15` resume drops the +Claude account (and cwd over relay) · `A16` project permission mode/default account ignored · +`A18` no lifecycle handling (8 s poll + relay stream keep running in background) · `A20` cancelled +connect leaks SSH and counts as failure · `A21` denied notification permission never re-requested · +`A22` back stack not saved across recreation · `A24` SecureStore overwrites identity on any decrypt +error (**data loss: fix early**) · `A31` silently dead SSH peer wedges "On your network" · +`A33` Windows host new-session cwd/account · `A34` Ctrl chip not applied to input-bar text · +`A36` terminal stays "Disconnected" after auto-reconnect. + +### WP5: iOS parity gaps + +`A25` real push (needs an FCM leg in the backend; also notification actions) · `A26` new session +and board edits over direct SSH · `A27` connect straight to a Linux host / Server Edition (also +probe `~/.nodeterm-server`) · `A29` source-control screen (verbs already in `HostConnection`) · +`A32` link opening and text selection in the terminal · `A55` merged inbox/usage across computers · +`A56` "Always allow" · `A57` multi-select questions · `A58` pace line / context indicator · +`A59` dictation · `A75` account picker shows UUIDs · `A76` wake/resume for Sleeping sessions over SSH. + +### WP6: low-severity, docs and CI + +The rest of `A37`–`A77`: R8 rules, lock-screen notification content, OSC 52 size cap, pairing +response size cap, SSH TOFU pin timing, device-to-device migration, the CI path filters and trigger +conventions, dependabot/CodeQL/wrapper validation, test gates on macOS/Windows, doc claims, the +tsconfig coverage of the interop fixture, and `ANDROID_APP_URL` pointing at a folder not on the +default branch yet. `A65` is partly obsolete (CI did build the APK). + +### WP7: PR + +When the user asks: open a PR from this branch, following the repo's `pr-writing` rules and any PR +template. Mention @eneskirca for the mobile implications: the pairing-key pin from `A07`, and +`approvals.answer` / `inbox.ack`, which the iOS app can adopt. + +## Decisions made (2026-09-26) + +1. **A07**: pin the phone's relay key at pairing (desktop + protocol change, additive). Done. +2. **A08**: refuse cold-create over direct SSH and route to the relay. Done. +3. **A09**: relay-route SSH-project nodes; the desktop attaches them over the project's ControlMaster. + Done. +4. **Distribution**: a committed public debug key for sideloading (`A10`). Done. A real release + key/store listing were deferred. The 2026-10-02 continuation prepares a private sideloaded beta: + sign locally with a retained private key; no public release or store listing is requested. + +## Device checklist + +Moved to [`android.md` → Device checklist](android.md#device-checklist) (`A65`): one numbered list, +grouped by area, each item naming the finding it checks, including every device check the fix +commits asked for. Its ledger is 10 Pass / 22 Partial / 32 Pending; new source features need their own checks. + +## Conventions for whoever continues + +- Develop on `claude/android-ios-parity-75kfem`, and push with + `git push -u origin claude/android-ios-parity-75kfem`. Commit messages end with the session's + attribution trailer lines. +- CLAUDE.md → Conventions → three surfaces has the Android rule. A change to a `host-service.ts` + verb, the `projects.list` blob, the pairing payload, the mirror file or the `~/.nodeterm` file + contracts owes the Android client and its interop fixture **in the same change**. +- Every fix gets a regression test where the layer allows. The protocol layer is JVM-testable, and + the WP1 SSH fixes are testable against the MINA/tmux harness. Fix the test fixtures that + hand-copied wrong shapes (`A02`, `A64`) so they would have failed. +- Before pushing, run: + - the protocol tests; + - the offline type-check; + - for desktop changes, the affected vitest files and `npm run typecheck`. + + After pushing, confirm the Android workflow is green. +- Update this file, `docs/android.md` and the audit index when work lands. Mark a finding as fixed + by editing its index row, e.g. `A02 ✅ fixed in `. +- Never claim device verification that did not happen. + +## Follow-up prompt + +Continue Android work on `claude/android-ios-parity-75kfem` of CPlusPlus17/nodeterm. +Read this handover, `docs/android.md`, the audit index and `android/README.md`; re-read current +source before editing. A100–A120 close retained history, typed read recovery, project launch settings, +agent policy, offscreen wake, held rule/question replies, SSH Git, owned Board/node actions and +host-owned managed New over SSH, revoke reporting, dictation language, host retirement, pairing +adapter choice, relative private-key Include exclusions and eligible legacy relay identity proof. +Retained-signer beta22/code23 from `2ba0b142` is installed on the separately authorized Pixel 7a. +Current source passes fresh forced1054/114/offlineapp and CI37732792493 all-five/ten-step. +The earlier beta21 source passed1033/110/app/TypeScript and CI37678829862. A fresh owned +40-ms/1200-row direct-SSH flow completes with its marker visible after Home/resume/scroll; the +original A132 negative remains historical. A133 source is published and its bounded same-page +retention, exact final-marker/Unicode clipboard paste and post-font OSC8/plain link offers pass. +The later normal browser/Share pilot records Vanadium's exact address-field nonce and the selected +chooser marker, with no recipient action. Browser Back remains unverified; ordinary resume succeeds, +while chooser Back returns to the owned terminal. Wider A131/TUI/lifecycle checks remain unverified. +Both copy/link and browser/Share fixtures are retired with exact owned cleanup. The original +15000-ms timeout is confirmed by read-only reconciliation after the sole restore's post-focus +uncertainty; no second write occurs. Current docs-only `9b8bba2b` also passes all-five/ten-step +CI `37690764287`; keep future publication receipts distinct from installed artifact source `ef4caec2`. +At Desktop `e06b5547`, its original A105 remembered-rule and A106 single/nonadjacent-multi choices +pass real sibling Claude application. Saved manual SSH profile/pin reuse after app process restart +and changed-host-key refusal pass. Both fresh fixtures, the QA profile/public-key admission and +owned reverse are retired with exact cleanup; the original phone timeout is restored. The original +Pixel 10 Pro ledger remains **10 Pass / 22 Partial / 32 Pending**, with testing paused. These +fresh-phone cases do not establish migration survival or wider acceptance on that original phone. + +At the earlier beta 17 checkpoint, `a79375c3` passed 982/102 protocol/app and affected Desktop +gates plus all-five CI. Its nine-case physical report binds 303 raw hashes for emacs/vi Send, +transport recovery, raw Ctrl, viewer-close/manual Reattach, managed creation and exact owned +End/cleanup. Those dated results and A128's source/configuration mutations remain historical. +The installed beta22 APK/runtime provenance is `2ba0b142`, separate from later docs-only +publication heads. Publish documentation only after fresh exact-revision offline gates and +all-five/ten-critical-step CI, keeping those receipts private. Preserve the installed beta22 APK. + +Use only the authorized Pixel 7a, verifying its serial and app focus before input. The user's +current availability/wake authorization persists; do not repeatedly request unlock while the +trusted endpoint and usable foreground are valid. Obtain a current main Wireless debugging +endpoint only if that trusted connection is unavailable. Every new physical case requires a fresh +owned fixture and untouched sibling guard; never restart a retired fixture. Resume the expanded +64-item checklist without inheriting older passes for new flows. A134's exact-revision gates/CI +and fresh original native create/visible prompt pass. A separate fresh consumer verifies native +capture retention and normal renderer reload/input with the same original kernel tuple and +untouched sibling; canvas gestures and Android history remain unverified. The controlled shell +comparison does not establish V13's child cause or whether fallback fired. +A135/A136 signed-source gates/CI and beta22/code23 update pass. One fresh no-Send A136 cover/Back +retains draft/Ctrl and its fixture/profile/auth/reverse are retired. Original15000 is independently +read back after the sole restore uncertainty without replay. Original beta21 loss stays historical. +Selection/IME/activity recreation and pending-Send/Ctrl follow-ups still need measurement. +A137 now identifies a source-confirmed pending/outcome visibility gap across retained-entry remount. +The exact-attempt/retained-guidance fix is published at `b9e4cbc7`; fresh1069/116/offline app, +CI37740459063 five/ten and retained-signer beta23/code24 artifact review pass. No update was dispatched +after the read-only pre-install snapshot refused. Revalidate the intended Pixel route, perform the +guarded in-place update, then measure a fresh pending-Send cover/Back case. Do not remove original +viewer/revision fences, replay an old command or treat A136's no-Send pass as pending-Send acceptance. +Next are A129/A130 native app history/held Live-touch acceptance, whole-host/network loss, +wider lifecycle/notification, native-route lost replies, network blackhole and remount/Ctrl during +pending Send. The measured held- and lost-successful-receipt/newer-draft cases pass separately. A105 rule +persistence after CLI restart, genuine production-pane-child launch and the wider question/layout/ +platform matrix remain pending. The bounded sibling CLI passes do not cover those paths. Beta22 is last verified installed; preserve historical proofs and all unrelated profiles/panes during the guarded update. + +Immediate FCM (A25) and fresh-different-desktop relay refusal (A93) need the hosted backend maintainers; +the user has no backend repository to supply. A111 implements a host-owned launch API on current +enabled local Linux/macOS tmux hosts; ordinary missing-session SSH attach remains attach-only. +Windows/non-tmux and third-machine creation are unsupported. Source is intended for an upstream +PR; none has been opened. A68 is source-fixed: push on main only, keep pull_request, no merge_group; +opt-in manual prepare_beta runs all five jobs. Flag all new mirror/verb/SSH contracts for @eneskirca's iOS client. + +Commit small logical changes with regression tests and isolated mutation checks. Before each push: +protocol `gradle test --offline`, offline app `gradle compileKotlin --offline`, affected Vitest and +`npm run typecheck` for desktop changes. Confirm the exact-head Android workflow green afterwards. +Local offline APK builds are authorized; keep the retained signer, APKs and private proofs out of Git. +Google Maven is blocked: use existing cached local tools or the real CI build, never try to reach it. + +## A113 + +**A legacy pairing can report a successful revoke without proof that relay access was removed (2026-10-04).** + +Fixed in `349b791a`. Desktop revocation now reports `unconfirmed` for a saved pairing with no +recorded relay key or no available revoker, and `retained` when another saved pairing still +authorizes that exact key. Settings shows these outcomes separately from local SSH-key removal +and Pro expiry. An absent relay outcome from an older main process also remains unconfirmed; +the confirmation dialog no longer promises removal of every connection. Confirmed last-key +unpin/session closure remains required for the clean relay receipt. Seven affected main/renderer +suites pass 111 tests; eleven isolated assertion mutants are caught with passing control/restored +runs. The source-bound mutation fixture uses a temporary-home service boundary and mounted React. +These additive local Desktop IPC outcomes do not change the phone wire contract. Full legacy +identity association/migration and physical relay revocation remain open; no unrelated approved +key is guessed or removed. + +## A114 + +**Android dictation has no language choice (A59 follow-up, 2026-10-04).** + +Fixed in `6b989e59`. Settings offers a searchable language/region picker using human-readable +locale names and an explicit System default. The phone-wide preference is separate from host +identities and is frozen when a dictation starts. System default omits the native language extra; +a chosen language sends its canonical BCP47 tag. Partial/final results still fill only the draft, +and microphone, cancellation and lifecycle rules remain unchanged. The bounded catalogue lists +locales, not installed speech models: native unsupported/unavailable errors stay visible. +Three focused protocol classes pass 39 methods, offline app compilation passes, and eighteen +isolated compiled mutants fail assertions with passing control/restored runs. Native intent/UI +wiring is covered by source guards; the cached Kotlin/Compose compilation is separate evidence, +not a recognizer/device pass. Physical language availability, recognition and layout checks remain +pending. No host wire contract changes. + +## A115 + +**Re-pairing leaves local host preferences and lets retired connections publish late (2026-10-04).** + +Fixed in `0a71c028`. Replacing a paired record retires its route, relay approval, +managed-creation checkpoint and old token while preserving unrelated computers and same-ID preference +updates. Forget and re-pair retire the exact old connection objects; a shared session-admission +barrier covers local record/token publication, including incoming IDs with no previous key match. +No network await runs under that barrier. A failed local secret write retains the record, while its +old socket remains retired. This is a local monitor fence, not a crash-safe Keystore/preferences +transaction. + +Owner-bound lifetime leases cancel pending dials and prevent late pin, LAN, relay-token, approval, +notification or checkpoint publication into a successor, even when it reuses the ID and key. +Normal disconnect still permits reconnect and retains an uncertain creation receipt; permanent +retirement never reconnects. Both primary and side relay results are retained before cancellable +handoff and closed on cancellation. Quiet notification reach uses the exact session without a +reverse manager lookup. Closing local polling does not revoke an existing Desktop SAS consent. + +Private controls/restored runs execute 177 repository methods and 29 actual +production HostStore adapter cases against in-memory Android interfaces. 73 isolated variants +fail assertions. One additional redundant retired-guard experiment survives equivalently because +retirement also invalidates every admitted generation; it is recorded separately and is not counted +as caught. Native wiring remains source-guard evidence, with app compilation checked separately. +No phone, live transport, physical durability or full legacy Desktop identity migration pass is +claimed. No host wire contract changes. + +## A116 + +**Pairing can advertise a Docker/VPN address the phone cannot reach (A49/A74 follow-up, 2026-10-04).** + +Fixed in `a6b3e88b`. Desktop Settings → Phone lists current IPv4 adapters and an Automatic choice. +The saved selection is an adapter name, so each new QR and authenticated relay LAN report follows +its current address across DHCP. An unavailable explicit adapter prevents a new QR and omits the +report's dial address; it never silently substitutes another adapter. Automatic prefers a physical +adapter on POSIX, retains Windows' validated current route hint, and permits a virtual-only fallback. +The list describes current local addresses, not proof that the phone can reach them. Windows remains +relay-only; browser Server pairing/network controls deliberately remain unsupported. + +Both Settings and quick pairing acknowledge pending settings writes before starting. Changing the +choice hides/stops the old QR first, offers an explicit failed-save retry, and cannot restart after +its visible row is hidden. Real global-search rows work too; hiding that row retires only its own +listener, including while its parent hook remains mounted. Old replies/events cannot stop or update +a newer pairing view. The legacy exported picker helpers also use the shared policy. + +Isolated Desktop controls/restored runs pass 156 methods; one unrelated pre-existing worker-spawn +guard is explicitly filtered in that private sandbox and remains part of the full merged gate. +Root's actual local HTTP-listener controls/restored pass all six methods. Five producer→Android +methods call the real policy, QR builder and LAN reporter with a fake OS table/public-key directory, +then the existing Android parser and LAN-refresh policy. They prove paired-host identity/pin +preservation, not Keystore or approval publication. The three final proof groups catch 56 assertion +mutants (32 Desktop, six real HTTP, eighteen interop); preliminary receipts remain separate. +The existing wire shape is unchanged and the Android client needs no parser change. iOS @eneskirca +should adopt the existing authenticated `lan` field; it now reflects the saved Desktop adapter. +Physical QR/VPN/DHCP, search UI and multiadapter checks remain pending. No external message sent. + +## Direct SSH coattachment follow-up (A13, 2026-10-04) + +Fixed in `ed2965be` after the beta-14 APK checkpoint. A current Desktop app attaches beside +external SSH/relay clients, then replaces only a live app painter attested by an exact PID/birth +receipt in the same private userData profile and tmux socket/session. Both old and incoming client +identities are rechecked in the tmux command queue before an exact-client detach. A failed or +departed new attach cannot evict the old viewer. Unknown, legacy unmarked and other-profile clients +survive; missing process attestation preserves viewers. Existing per-manager joins and grid policy +remain. Exit/quit releases the exact receipt; only proven stale receipts are pruned within bounds. + +Private controls/restored pass 122 focused tests and full TypeScript checking. Twenty-four distinct +isolated mutations fail assertions; three critical variants are additionally caught against the +actual tmux server. Seven real private-socket tmux control-client methods pass without skips on +tmux 3.7c: both phone/app attachment orders, unequal native pane grids, remounts, foreign/legacy +clients and stale-client races. Control-client height formatting is empty on this host, so grid +evidence reads the native pane and checks phone-only restoration. + +An additional Linux smoke at source `32d412b6` passes eight checks using cached Electron 42.11.3, +Node 24.19.0, actual node-pty and tmux 3.7c. It exercises the production `PtyManager` and painter +tracker in a private home/tmp/run/PID/network namespace: external/app attachment in both orders, +same-manager joins, selective app remount takeover with exact shell output retained, and normal +quit receipt cleanup while the external client and shell survive. Observed native pane grids +return to 56×48 after the 100×40 app replacement leaves. The in-memory `CorePlatform` adapter is +an explicit IPC boundary; no actual renderer, SSH handshake, relay, Pixel, macOS or Windows runtime +pass is claimed. The cached native binding, Electron binary, all 96 bundled source inputs and +external node-pty wrappers are hash-bound and unchanged across the final run. + +The private painter receipts are internal app state, outside the phone's named workspace/status/ +project files; no host-service verb, payload, mirror or client wire shape changes. Android keeps +its exit-zero reattach fallback for older hosts. The same host policy preserves iOS SSH clients; +@eneskirca should verify physical coattachment. Prepared beta 14/code 15's Android client remains +unchanged and uninstalled; host-side verification uses the later source. The device ledger stays +10 Pass / 22 Partial / 32 Pending, and phone testing remains paused. + +## A117 + +**Relative private HostKey declarations can be opened by a configuration Include (2026-10-04).** + +A relative `HostKey` is still excluded from SSH anchors because discovery does not know the +daemon's working directory. Its normalized private basename now also excludes lexical and +canonical Include candidates before they are opened. Explicit public `.pub` declarations remain +readable, and relative declarations share the existing bounded declaration budget. This is a +conservative exclusion by name; it does not infer relative paths or discover unknown aliases. + +All 22 host-reader methods pass on this Linux host, including the existing real FIFO race. +Seven isolated reader mutations fail assertions with passing controls/restored runs. The real +pairing-service → Android regression seals only a disposable server's permitted key, preserves +the anchors in the phone record, accepts that real SSH server and refuses a second real server. +The original source and removal of the canonical exclusion both fail that regression; restored +source passes. Private-file inputs are synthetic config sentinels, never actual private keys. +Proof is retained in `.nodeterm/android-relative-hostkey-2026-10-04/`. + +The sealed field and Android production code are unchanged; the actual interop fixture is updated +with the producer. iOS @eneskirca should verify the existing anchor handling against the current +host. Prepared beta 14 remains valid client source and is not installed. Physical checks remain +pending; phone testing is paused and the ledger stays 10 Pass / 22 Partial / 32 Pending. + +## A118 + +**Eligible older pairings cannot associate their existing relay key with a saved device (2026-10-04).** + +The standing phone host now offers `pairing.relayKeyChallengeV1 {pairingId}` and +`pairing.relayKeyProofV1 {challengeId, signatureB64}` only after the normal relay approval gate. +A legacy pairing is eligible only when its exact host-minted UUID has one unambiguous, canonical +Ed25519 public key under the host's existing `nodeterm-ios-` authorized_keys attribution. +Missing, option-bearing, duplicate, malformed, replaced or unsafe files refuse proof; relative +identity guesses and device names are never used. + +Each connection holds at most one ephemeral challenge and one in-flight inspection. It binds +version, challenge UUID, pairing UUID, pinned host box key, authenticated peer box key, raw SSH +public key, random nonce and expiry. All fields use the fixed newline encoding in +`src/shared/relay-pairing-proof.ts` and Android `RelayPairingProof.Challenge.signingBytes`. +Both wall and monotonic host time enforce 60 seconds. Every proof attempt consumes the challenge +before awaiting publication, including malformed signatures; another session cannot use it. + +Android attempts the optional proof only after an approved first listing, using its existing +encrypted SSH seed. It validates the captured host, peer and SSH keys before signing, checks the +current record/lifetime around awaits, and never creates an identity, changes approval or tokens, +or retries an uncertain proof. Missing identity, unsupported older hosts and ordinary optional +errors preserve browsing; cancellation propagates. A socket closed during proof cannot be handed +off as connected, and a later network close after proved approval is not reported as Deny. + +The actual host pairing queue stages a private association, then rechecks the bounded exact +registry snapshot, attributed public key, approval, connection lifetime and expiry immediately +before synchronous atomic publication. Existing same-key association is idempotent; conflicts and +deleted/replaced entries refuse. This is a same-service queue, not a cross-process transaction. +Normal revocation can then unpin/cut the exact proven last key; a sibling pairing still authorizing +that key keeps the explicit retained outcome. + +Focused controls pass 87 host and 15 Android helper methods. Forty compiled host and 25 compiled +helper mutation variants fail assertions. Seven real producer→Kotlin encrypted interop methods +cover sealed legacy pairing, actual signature verification, exact publication/revoke, distinct and +shared-key siblings, wrong SSH identity, cross-session/consumed challenges, old-host fallback, +unapproved access and the close/callback ordering. Six additional interop variants fail assertions +with passing controls/restored. The public-pin adapter, human decision, OS address and unused PTY +are explicit fixture boundaries. Private evidence: `.nodeterm/android-legacy-relay-proof-2026-10-04/`. + +This is an additive host/Android contract. iOS @eneskirca needs the same retained-key signer and +exact encoding to repair eligible legacy records; old iOS remains usable. A phone without its +retained SSH seed, its matching attributed SSH public key in the host's authorized_keys or SSH support still needs re-pairing. This proof never +bypasses first SAS approval and does not solve hosted FCM or A93. New Android source needs a later +APK than beta 14; no new installation or physical/live transport pass is claimed. Phone testing +remains paused, beta 10/code 11 is last installed, and the ledger stays 10 Pass / 22 Partial / 32 Pending. + +## Prepared private beta 15 and verification checkpoint (2026-10-04) + +`0.1.0-beta.15` / code `16` is built from clean signed source `aa902d0a52ba8ccfa0be57e073c350e90cfad5d3` and includes +A118's optional retained-SSH legacy relay identity proof. The host must also run this source for +migration; older or unprovable hosts remain browsable. A117's relative private-key Include fix and +A13's coattachment fix are included in the host source. + +Required source checks pass: 912 protocol methods / 94 suites, zero failures/errors/skips; +offline app compile; 1,919 affected desktop methods with exactly ten known macOS/Windows skips; +full TypeScript checking. All five exact-source Android jobs pass: [source CI](https://github.com/CPlusPlus17/nodeterm/actions/runs/37228985208). +A118's focused proof remains 87 host and 15 helper methods, 40 + 25 compiled assertion-caught +mutants and seven actual producer→Kotlin methods with six additional assertion-caught variants. + +The local release build uses a fresh tracked Android-only snapshot, cached AGP 8.10.1/Kotlin 2.2.0/ +Gradle 8.14.3/JDK 21 and `--offline`. R8 runtime keeps and 56 independent artifact checks pass: +SDK 36/37 signatures and 16 KB alignment, retained signer, actual version/manifest/provenance, +service-loader mappings and unchanged terminal/native assets versus beta 10. +Private APK: `.nodeterm/android-beta-15/nodeterm-android-0.1.0-beta.15.apk`. +SHA-256: `c1e707c8a18555db7e01129b709d39db342128f3c54d8103d548cc177bc9abaf`. +Signer SHA-256: `c610c3a0b637a8005b6fd858587e8cbf5260622648bc1cd97c0de0ab5f146dbd`. Private build/review evidence: +`.nodeterm/android-beta-build-15/`. The APK, signer and proof remain outside source exports. + +Live A105/A106 application is still unverified. An isolated installed Claude 2.1.289 fixture uses +the actual managed hook server/script/writer with an in-memory CorePlatform. Its fresh-context, +read-only credential/config and fixed-tool guards pass 34 static checks and the exact offline +mount/auth gate. The first attempt hid the system DNS target and timed out before tools; a +separate corrected fixture exposes only that regular resolver file read-only. It reaches the +provider, but receives authentication HTTP 401 before any tool request. No application pass is +claimed. Original profile/source/binary/resolver metadata remains unchanged; raw diagnostics stay +private and no credential refresh/reset is attempted. After the existing CLI account is usable +for inference, rerun the isolated remembered-rule/full-question checks. This gives no physical, +renderer, SSH or hosted-relay proof. + +Phone testing remains paused. Beta 10/code 11 is last confirmed installed; beta 15 is prepared +and not installed. The ledger stays **10 Pass / 22 Partial / 32 Pending**. Next device work uses +this APK and current host source, checks saved key/pin survival and the expanded checklist, +then restores rotation and removes only owned QA host state. A25/A93 still require hosted-backend +maintainers; iOS @eneskirca owes the new proof contract and earlier mirror/verb/v2-answer adoption. +No PR is opened; A68 stays deferred until requested PR preparation. + +## A118 JUnit registration follow-up (2026-10-04) + +The focused Kotlin runner exercised all 15 helper methods, but the initial Gradle/JUnit run +registered only 14: the outer-timeout coroutine test inferred an exception-valued return. +Its explicit `runBlocking` signature now lets JUnit discover it. Actual offline Gradle +controls register all 15 with no failures/errors/skips. Removing the signature compiles and +passes 14 methods, then fails the exact method-registration assertion; restoration passes 15. +The seven actual host/Android interop methods were already registered. Private evidence: +`.nodeterm/android-legacy-relay-proof-2026-10-04/junit-registration/`. + +The required local gate now checks the actual JUnit helper inventory and the outer-timeout +method explicitly. This correction changes only a protocol test and documentation after +beta 15's `aa902d0a` source; its shipped Android runtime inputs and private APK are unchanged. +The APK remains prepared and uninstalled, live Claude application remains unverified after +provider HTTP 401, and the physical ledger stays 10 Pass / 22 Partial / 32 Pending. + +## A119: explicit SSH profile folder (2026-10-04) + +Add SSH server now accepts an optional absolute **Profile folder**. Existing computers can change +**SSH profile folder** in Settings → How to reach each computer. The saved phone-local choice +selects a custom Desktop/Server data directory ahead of all automatic discovery. Blank restores +discovery. A missing or invalid explicit choice reports an error and never selects another profile. +Board, project reads and all three managed-view stages use the same captured profile. Saving a +change invalidates pending connection work and clears old displayed rows while retaining uncertain +creation receipts; it never recreates a session. SSH host pins, pairing and relay credentials remain +unchanged. Paths are quoted literally and reject relative/dot components, controls and oversized input. + +Regression coverage uses actual Server config/platform/WorkspaceStore/mirror/actions producers, +a real private MINA SSH server and isolated tmux. The fixture supplies a plain-shell planner/native +create boundary; it does not run full Server boot, installed hooks or account probes. Native storage +coverage uses actual app classes with in-memory Android adapters. Ten protocol model methods, +three actual Server/SSH methods and the complete 62-method SSH/10-method workflow suites pass. +Four compiled SSH profile mutants fail their intended assertions. Final native control/restored +runs pass 19 cases and catch the retired-session mutation; seven earlier native variants have +separate historical source bindings. Private receipts: `.nodeterm/android-ssh-setup-2026-10-04/`. Physical setup/profile switching remains unverified; +phone testing is paused and the ledger stays **10 Pass / 22 Partial / 32 Pending**. At this source checkpoint, beta 15 was +the latest prepared APK and excluded A119. The beta-16 receipt below records its later packaging. +iOS implication for @eneskirca: offer an explicit saved SSH profile choice consistently for discovery +and managed attachment. No host verb or pairing payload changed. A25/A93 and A68 remain open; +no PR is opened. + +## A120: one-time password SSH setup (2026-10-04) + +Add SSH server keeps its public-key Copy/Share/manual command and adds **Set up with a password**. +The app inspects the SSH host key without authentication. Compare its fingerprint on the computer, +then explicitly confirm **I compared it — it matches** before entering the password. Password login +rechecks that exact key, installs only the retained phone public key, closes the setup connection, +and proves a separate pinned key-only login before saving the host. A changed key, refused password, +unsafe key-file target, uncertain command or failed key verification never saves a computer. +No setup write is automatically replayed. A lost acknowledgement may leave the public key installed; +use the normal key connection or check authorized_keys on the computer before starting a new attempt. + +The password is memory-only, absent from saved state/preferences, shell text and diagnostics. Leaving +or backgrounding setup cancels owned sockets and clears its password/fingerprint approval. JVM/library +transient copies cannot all be erased. Existing keys and restrictions are preserved; commented or +trailing key-like text is not an installed declaration. Symlink, nonregular, foreign-owned and hardlinked +targets refuse. These are bounded pathname preflight checks, not descriptor-atomic protection against +concurrent same-user replacement. Keyboard-interactive/MFA, password changes and Server installation +remain unsupported. Host pins, pairing/relay credentials and ordinary key-based connections retain +existing behavior. iOS implication for @eneskirca: confirm fingerprints before password auth and verify +fresh retained-key auth before saving; no host-service verb or pairing payload changed. + +Seventeen actual protocol methods pass against a private MINA SSH server and private POSIX HOME. +They cover fingerprint/auth order, other/restricted keys, uncertainty/no replay, both-stream output +limits, own timeout versus external cancellation and socket closure. Offline app compilation passes. +Seven isolated compiled semantic variants fail assertions, with 17 passing control/restored +methods and exact integrated source hashes. Private proof: `.nodeterm/android-ssh-setup-2026-10-04/password-final/`. +The full required gate verifies actual Gradle/JUnit method registration too. This is source/fixture proof. +Actual Android setup UX, ordinary PAM/account behavior and macOS runtime checks remain pending; +the controlled native OpenSSH receipt below adds wire/exec evidence. Phone testing stays paused, +with beta 10/code 11 last installed and **10 Pass / 22 Partial / 32 Pending**. Beta 15 excluded A119/A120; +the subsequent beta-16 receipt below includes both. A25/A93 require the hosted-backend maintainers; A68 stays deferred and no PR opens. + +## Prepared private beta 16 and verification checkpoint (2026-10-04) + +`0.1.0-beta.16` / code `17` is built from clean signed source +`2723845efac75cfcf2cd0a9fdfa11d9496699641`. It includes A119's explicit saved SSH profile folder and +A120's opt-in password enrollment, along with the earlier beta-15 changes. Missing explicit profiles +never fall back to another profile; enrollment saves a host only after a separate pinned key login. +Full offline protocol checks pass **944 tests / 98 suites**, with zero failures, errors or skips. +Offline app compilation, **151 affected desktop Vitest tests / eight files** with zero skips, and +full TypeScript checking pass. Actual JUnit inventories include the new setup tests. An obsolete +inline UI behavior source pin was removed; actual SSH authentication regressions remain. +All five Android jobs pass for the APK source: +[workflow 37233114167](https://github.com/CPlusPlus17/nodeterm/actions/runs/37233114167). + +The minified release build passes offline in **54.87 seconds**. Packaging uses the retained private +signer; all **56 independent artifact checks** pass, including official SDK 36/37 signatures, +nondebuggable version/manifest, R8/runtime keeps, payload/provenance and 16-KB ZIP/native alignment. +APK SHA-256: `9c5309c8cfb225daf59f7fac70be1760fb0abdaac36cb55005b958f2186c8ce3`. +Private APK: `.nodeterm/android-beta-16/nodeterm-android-0.1.0-beta.16.apk`. +Build proof: `.nodeterm/android-beta-build-16/`; behavioral/mutation proof: +`.nodeterm/android-ssh-setup-2026-10-04/`. Earlier failed or historical runs retain their separate +receipts; they are not counted as successful merged gates. The release receipt changes only docs. + +**Prepared, not installed.** Phone testing remains explicitly paused, beta 10/code 11 is last +confirmed installed, and the device ledger stays **10 Pass / 22 Partial / 32 Pending**. +When authorized, install beta 16 as a same-signer update and verify saved identities, profile +selection/switching/no fallback, password/fingerprint/cancellation UX on actual OpenSSH, and the +remaining managed-session, relay/cellular, notification and lifecycle matrix. MINA/POSIX fixtures +and in-memory app adapters do not verify physical Android or macOS behavior. Live Claude rule and +question application remains unverified after provider HTTP 401. A25 FCM and A93 fresh-desktop +relay recovery require the hosted-backend maintainers. For iOS, @eneskirca should adopt the saved +profile/enrollment UX and review the earlier host protocol changes. No PR opened; A68 remains +deferred until a PR is requested. Every push requires its own exact-head green Android workflow. + +## Native OpenSSH enrollment verification (2026-10-04) + +A120 now has an actual **OpenSSH 10.2p1** control run in addition to its MINA regressions. +The actual protocol sources at `bac1aa7fe90416b07dd0de47ac95049e817ce8b5` were compiled privately; +**28 distinct assertions / 40 passing assertion events** verify inspection against an independently +measured public fingerprint, password login followed by fresh pinned key login and exec, +retained unrelated keys, newline/mode handling, idempotence, consumed confirmation, rejected password +and host pin, commented key text, restricted keys, symlink/nonregular targets, and closed client sockets. +These are native fixture assertions, not additional Gradle/JUnit methods or physical checklist passes. + +The daemon ran as UID 1000 in a filesystem/user/PID sandbox exposing only read-only `/usr` and private +account files/home. Its password and SSH identities were newly generated for this test. It listened +only on loopback, with PAM, keyboard-interactive, forwarding, PTY and source penalties disabled. +This Fedora build warns that `UsePAM no` is unsupported; the asserted crypt/password and public-key +wire/exec flow nevertheless passes in that exact controlled configuration. This does not verify a +normal PAM/account backend, MFA, macOS, Android setup UX/Keystore, native cancellation/timeouts, +or profile/managed-session behavior. The earlier MINA tests retain their separate coverage. +Both disposable daemon runs are closed; their generated passwords/private keys/account hashes were +removed after the run. No host account, credential, SSH configuration, retained phone identity or +phone state was changed. Private proof: `.nodeterm/android-beta-build-16/openssh-native/`. + +The stale SSH class comment now describes A111's host-owned managed New support; missing existing +canvas sessions remain attach-only. It changes only KDoc, with the same source line count. +The prepared beta-16 APK remains source `2723845e`, code 17 and the same retained signature/checksum; +no new APK is built or installed for this verification/comment checkpoint. Fresh compilation in the +same AGP graph confirms every protocol class byte matches the immutable beta-16 build. This byte +comparison is a required gate before publishing the receipt. Required local gates and exact-head Android CI also remain mandatory. +Phone checks stay paused at **10 Pass / 22 Partial / 32 Pending**, with beta 10/code 11 last installed. +Actual Pixel setup/lifecycle, ordinary PAM accounts, macOS and live Claude checks remain open; +A25/A93 still need hosted-backend maintainers, iOS follow-up stays with @eneskirca, and A68 remains +deferred until a requested PR. No PR opened. + +### Relay-advertisement producer/SSH coverage (`A64`, 2026-10-05) + +Two actual private-SSH regression methods now check every public field from the desktop's +account-level `relay.json` writer, replacement, removal and profile isolation. A third method +checks the actual writer/remover and `fs-atomic` bundle inputs against Android CI path coverage. +All 75 affected protocol methods pass with zero failures/errors/skips. Four isolated behavior +mutants fail assertions, and control/restored runs pass. Private proof: `.nodeterm/android-beta-build-16/desktop-links-and-advertisement-receipt/a64/`. +The producer uses a temporary OS-home adapter during module initialization, restored before its +operations; no standing relay host, token mint, adoption, SAS, revoke or phone result is claimed. +No production contract or APK changes. Beta 16/code 17 remains prepared, beta 10/code 11 remains +installed, and phone checks stay paused at **10 Pass / 22 Partial / 32 Pending**. + +## Canvas terminal link leave follow-up (A121, 2026-10-05) + +The full Linux Desktop native hover check for A92 exposed a separate Canvas leave defect: +synchronous blur redraws xterm's DOM rows before its screen receives mouseleave, leaving the +link pointer active after a native non-drag leave in the SGR mouse case. A121 now defers only +owned blur to the next timer task and cancels it on re-entry, focus and cleanup before park. +Nine behavioral methods, all 63 affected terminal tests, full TypeScript and five assertion +mutants pass. Fresh full Linux Desktop control and restored builds each pass all eight native +UI cases at `01b4a2f5`; the old-bound A92 and synchronous-blur A121 mutants fail on their named +hover/leave assertions after trusted mouse delivery. Separate Linux Server execution now passes at +`dcdf664a`. The later GPU-enabled eight-case matrix passes (Canvas4 hardware / Modal4 DOM); GPU +context recovery and native macOS remain unverified. No Android runtime, APK, external contract +or iOS change is involved. Phone testing remains paused: beta 10/code 11 is last installed, +beta 16/code 17 is prepared, and the physical ledger stays **10 Pass / 22 Partial / 32 Pending**. + +## Native Desktop wrapped-link and leave verification (A92/A121, 2026-10-05) + +Signed source `01b4a2f5` now passes the full actual Linux Electron Desktop UI check. Four fresh +full builds consume all **2,462 exact raw Git blobs**, with only the one approved source literal +changed in each mutant. Control and restored runs each pass all eight combinations of Canvas +and maximized Board modal, hard/soft wraps, and normal/SGR mouse reporting. A real plain native +NodePTY paints the long run; native trusted XTest tail hover shows the entire URL underline and +pointer, plain click opens nothing, Ctrl-click opens exactly the complete URL once, and a native +non-drag leave clears the pointer and underline. No direct provider or activation call is used. + +The old upward cap fails with `A92_TAIL_HOVER_MISSING` after calibrated native tail hover. +Restoring synchronous Canvas blur fails with `A92_HOVER_CLEAR_FAILED` after trusted hover, full +Ctrl-click activation and native leave, retaining pointer/link state. The final blur mutant fails +in Canvas soft/SGR; an earlier run also reproduced it in hard/normal mode. Nine controller tests +and five assertion mutants cover cancellation, replacement, same-object park/adopt and cleanup +reuse; all 63 affected terminal Vitest methods and full TypeScript pass. + +Private durable proof: `.nodeterm/android-beta-build-16/desktop-links-and-advertisement-receipt/` +contains `a92/results.json` with 72 source/build/runtime evidence files, `a121-unit/results.json` +and `a121-affected/result.json`. Network/PID/home isolation and unchanged source/tools/held output +checks pass. Earlier archive-line-ending refusal, phase-specific classification rejection and +quit timeout are preliminary recipe results; they were not counted as product failures or final +controls. The final disposable fixture uses explicit exit after recording the checks; ordinary +app quit is outside this proof. Synthetic SGR reporting does not verify a real TUI/tmux/SSH path, +Server Edition, GPU, macOS or Android. No APK, host/client contract, iOS adoption or physical +checklist promotion follows. Phone testing remains paused at **10 Pass / 22 Partial / 32 Pending**. + +## Native Server wrapped-link and leave verification (A92/A121, 2026-10-05) + +Signed source `dcdf664a4ffa0cf752350c29fc21843f74c93ae1` passes separate genuine Linux Server +control and restored runs, each with all eight Canvas/Board-modal, hard/soft-wrap and normal/SGR +cases. Four fresh full builds use 2,462 raw Git blobs; each separate `server:build` adds only +`server/main.cjs` to the unchanged 136 artifacts. Actual Node 26 runs the shipped Server entry. +Chromium loads its real login form and HTTP renderer with no Desktop preload, Node integration +or injected API. Passive observation confirms authenticated `/ws` handshakes, actual PTY create +requests/replies and binary output. Native trusted hover, full underline, plain/Ctrl click and +non-drag leave assertions pass. Both the old-cap and synchronous-blur mutants fail their named +assertions after calibrated native input; restored source passes again. + +Private proof: `.nodeterm/android-beta-build-16/server-links-receipt/a92/results.json` and its +105-file manifest bind source, builds, recipes and runtime. Three earlier browser-bootstrap +failures remain separate infrastructure evidence, with no terminal assertions or product finding. +Loading the actual login document before enabling debugger network observation resolves the +runner stall. Final owned Node children close on SIGTERM; disposable Chromium exits explicitly +after the report. This verifies Linux Server DOM rendering with a synthetic reporting-mode TUI; +real tmux/SSH, GPU, macOS, ordinary quit and Android UI remain outside its scope. + +No runtime or external contract changes follow from this verification. Beta 16/code 17 remains +prepared from `2723845e`, uninstalled; beta 10/code 11 is last installed. Phone testing stays +paused at **10 Pass / 22 Partial / 32 Pending**. Next: resume the Pixel update/checklist when +authorized; live A105/A106 needs a usable provider account after HTTP 401; A25/A93 need hosted +backend maintainers. The Android contribution is in this repository. No PR is opened; A68 waits +for requested PR preparation. + +## Native setup timing, installed PAM and remaining desktop checks (2026-10-05) + +A120 now passes four native OpenSSH cancellation/deadline cases at clean source +`7cf2bb339d73c98e7876a7ce1edec5f6f3585141`, with **76 case-scoped assertions**. +All 76 protocol source files were compiled privately with Kotlin 2.2/JDK 21 and SSHJ 0.39. +The pre-install gate admits real password authentication and the exact production exec request, +then holds it without running the installer; cancellation/deadline leaves authorized_keys unchanged. +The post-install gate retains the actual installed key, accepts a third connection and reads a +real upstream SSH banner before withholding it. That case does not complete a third key exchange +or key authentication. External cancellation remains CancellationException; the operation's own +12-second post-dispatch deadline reports HostUnansweredException. Neither returns a PairedHost. +Owned client sockets, gates and daemon descendants close; caller password buffers clear, consumed +confirmations cannot replay, unrelated key bytes/modes survive, and generated secrets are removed. +This is protocol/native proof, not physical HostStore, Keystore or Android background/setup UX proof. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/openssh-timing/`. +The earlier 28 distinct native assertions / 40 events and 17 JVM methods / seven product mutants +retain their separate inventories; these new fixture checks are not new JUnit methods or mutants. + +A separate **17-check native installed-PAM control** passes against OpenSSH 10.2p1 and this host's +unchanged Fedora PAM stack. It uses a namespace-only synthetic NSS/shadow account and a UID/GID +1000 client with every process capability cleared. Actual logs record one accepted password, +two accepted key authentications, three successful PAM account checks, and three matched session +opens/closes, with no PAM session/credential errors. Public fingerprint confirmation, independent +retained-key exec, wrong-password/pin refusals, key-file preservation, no replay and socket cleanup +pass. All 31 public installed-stack bindings remain unchanged, SELinux remains enforcing, and +generated credentials plus owned daemon descendants are removed. This reuses 513 class files from +the earlier timing compilation after verifying all 76 protocol source bytes; only the 11 standalone +driver class files are freshly compiled. It does not verify real-user policy, MFA, ordinary systemd +user-session registration, macOS, or Android UI/storage. Earlier setpriv and private-log traversal +failures are retained as failed fixture attempts, without product fixes or mutation credit. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/runtime-checkpoint-supplement/`. + +The earlier RTX 4090 WebGL2 probe verified the environment only; the successful PAM namespace +admission performed no authentication. Their historical scope is unchanged. Later product GPU +and installed-PAM results supply the separate evidence recorded here. + +Earlier focused-terminal Ctrl+Q attempts remain nonpositive. V5's trusted renderer capture and +main before-input observations do not establish `defaultPrevented` after xterm's target handler. +Minimal Electron Quit controls verified the environment, not product Quit; failed fixture and +classifier attempts remain separate from the successful runs below. + +**Historical Linux Desktop runtime checkpoint:** current source `972f7994` differs from built +Desktop `01b4a2f5` only in six documents. Native File-menu Quit/Cancel and a separate outside-terminal +Ctrl+Q/Cancel both pass, followed by native File-menu confirmation and natural zero exit. Cancel +preserves the exact own PTY/workspace/window; Quit retires the own PTY/hook and saves state. +The GPU-enabled eight-case link matrix passes with eight complete URL opens: four Canvas cases +use the actual NVIDIA RTX 4090 xterm WebGL2 context, real glyph pixels and production 2D link +underline before/hover/leave; four Modal cases intentionally use DOM. The single GPU pilot stays +separate. A real shipped-tmux Canvas soft-wrap/inner-no-reporting pilot also passes one native +hover/plain-click/Ctrl-click/leave case while preserving the exact pane/client and shipped mouse-on +options. GPU and tmux fixtures use explicit `app.exit(0)`, supplying no ordinary-Quit proof. +These do not verify GPU context recovery/compositor pixels or full tmux/SSH matrices. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-checkpoint-20261005/`. + +**A122 is fixed in `9613cec519c1469aff10371bf0acf97adb90ed1a`.** Desktop Canvas/Modal now keeps the +exact native Quit chord ahead of xterm on keydown/keyup under either shortcut policy, without +preventing the native menu. Server retains Ctrl+Q/XON. 133 focused policy tests, 218 affected tests +in three files, full TypeScript and six assertion-caught isolated mutations pass. The fresh full +Desktop build is bound to that exact source (snapshot `57e09375369a1a68f0c28b7975616561aad6715a4dc05a6fc5eeb00d0dc0706c`; +build receipt `86eb82912ab7aa8a853a7236bacebc40ffbd9eff6845f3b79caaac83a029f5ff`). +All four native Linux cases pass: Canvas/Modal × app-first/terminal-first. At dispatch the actual +owned xterm textarea and main focus IPC are true; Ctrl+Q opens the real parented Quit dialog. +Native Escape Cancel retains the exact PTY and saved state; native File → Quit / Tab / Enter then +exits naturally with code zero, retires every admitted PTY birth and removes the own hook endpoint. +No signal, `app.exit`, renderer-dispatched key or API Quit supplies a pass. These are four separate private +home/network/PID/Xvfb cases, with zero link cases/URL opens; native macOS/Windows remains unverified. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-native-quit-20261005/`. + +The A122 source repair changes Desktop shortcuts only; Android and external contracts are unchanged. +Beta 16/code 17 remains prepared from `2723845e`, uninstalled; beta 10/code 11 is last installed. +Phone testing stays paused at **10 Pass / 22 Partial / 32 Pending**. The later tmux/SSH and +GPU follow-up below keeps its own source bindings; other-platform runtime remains unverified. +A105/A106 live checks need a usable provider after HTTP 401; +A25/A93 need hosted-backend maintainers. Resume Pixel checks when authorized. No PR is opened, +and A68 remains deferred until requested PR preparation. + +## Tmux, SSH and GPU recovery follow-up (2026-10-05) + +The fresh full Linux Desktop build at `6bfa5412` passes two separate eight-case matrices: +Canvas/Board modal × soft/hard wrap × inner mouse reporting off/SGR. Each matrix delivers +52 native mouse actions and eight complete URL opens through shipped xterm handlers. The local +matrix uses real shipped tmux; the remote matrix uses an actual SSH project, production +SshProjectManager/OpenSSH ControlMaster and remote `nodeterm-rmt` tmux. Exact pane, server and +client births survive every case. SSH retains its connection notice in history and calibrates the +actual viewport offset; the fixture does not erase it. Own session and SSH service cleanup pass. +These fixtures end with explicit app exit and supply no ordinary-Quit or SSH-reconnect proof. +Private proof: `desktop-tmux-matrix-20261005/` and `desktop-ssh-matrix-20261005/` below +`.nodeterm/android-beta-build-16/operation-verification-receipt/`. + +Two separate one-terminal GPU pilots at that same source pass delayed loss → readable stock DOM +→ fresh NVIDIA WebGL2 and quick original restoration → DOM → fresh WebGL2. Both retain the same +49×48 buffer/PTY and three trusted native input acknowledgements, with complete URL interaction +and glyph checks before/after. Quick restoration also exposes A123: the detached original +context remains live after the new renderer is granted. Chromium cap-pressure consequences are +an inference; no page-wide pressure failure was reproduced. + +A123 is source-fixed in `db83fb7d`: retire the captured original context after addon disposal and +before reporting context loss to the budget coordinator. Two new behavioral tests cover detached +canvases and retirement continuing after a throw; all 44 budget tests and 301 affected tests in +seven files pass with full TypeScript checking. Full 44-test control/restored helper runs catch +two exact assertion mutants. Three separate fresh full Desktop builds at `db83fb7d` verify the +actual restore hook: both fixed controls release the original context before fresh regrant, while +the compiled sole-call-removal mutant fails only `GPU_RETIRED_CONTEXT_NOT_LOST` after all three +native input acknowledgements and both complete URL controls pass. A capture-phase native event +witnesses the genuine restoration before production retires it; a passive second-loss event and +DOM/fresh/final checks verify retirement. Fixture probes retain the original context for observation; +no forced GC or second fixture loss supplies a pass. These are one-terminal Linux Canvas pilots, +with explicit fixture app exit rather than ordinary Quit. Private proof: `gpu-context-recovery-20261005/` +for historical recovery and `a123-context-retirement-20261005/` for the new source/compiled mutants. +The older 6bfa and A122 source/build bindings remain separate. + +**Additional Server verification (`fa259f6e`).** Three fresh genuine Linux Node Server/browser +Canvas soft/none runs pass fixed control / sole-call-removal mutant / restored confirmation +with natural fixture exits 0 / 3 / 0. Each authenticates through the shipped HTTP login and WS +bridge, without Desktop preload, and retains the same plain NodePTY, producer, 49×48 +buffer and geometry. Three trusted native input ACKs and two complete URL/glyph controls complete before +the retirement assertion. Fixed runs witness genuine original restoration, production loss of that retired +context, stock DOM fallback and fresh NVIDIA rendering; the calibrated mutant fails only +`GPU_RETIRED_CONTEXT_NOT_LOST`. Owned Server SIGTERM closes it gracefully with zero and removes +its PTY/producer; fixture app exit supplies no ordinary-Quit proof. This is one visible Server +Canvas pilot, not a Server GPU matrix or pressure test. + +**Additional SSH reconnect verification (`fa259f6e`).** One Linux Desktop Canvas soft/none +pilot passes after a single controlled SIGKILL of the exact product ControlMaster. Actual SSH +exit 255 triggers shipped automatic recovery; a separate strictly pinned read-only observer +verifies the same remote tmux pane and server, session-created identity, full history and producer across +the gap and recovery. A new master, local PTY and remote attachment are required. Two trusted +native `g` ACKs and complete URL hover/plain-click/Ctrl-click/leave checks pass with one producer +paint. Exact owned session, observer, master and daemon cleanup passes; explicit fixture app exit +provides no ordinary-Quit proof. Observer reads may affect timing, so this proves no performance +or backoff guarantee. Broader SSH reconnect matrices remain pending; this pilot supplies no +Modal, Server, Android or PAM enrollment proof. +Earlier V2 nonce admission and V3 service-process failures remain fixture negatives; V3's outage +state was not established. Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-ssh-reconnect-20261005/`. + +**Derived Desktop compositor evidence (`db83fb7d`).** A separate hash/CRC-checked analysis of +three existing positive-run `capturePage` PNGs compares an uncovered repeated `A` cell and a blank +cell with the actual framebuffer RGB references. All three match 152/152 pixels in each cell; +42 glyph pixels differ from the blank background. The Sessions-covered top-left cell is excluded, +not a product failure. Recorded geometry establishes 1:1 capture/CSS/GL scales at zoom 1; +a direct device-pixel-ratio query was not recorded. This adds a single-run compositor cell +correlation without rerunning the product or relabelling its old receipts. It does not establish +synchronized frames, whole-frame or physical-display output, or link-underline compositing. + +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/a123-server-context-retirement-20261005/` +contains **80 evidence/recipe files plus its manifest**. Server runtime and historical Desktop +cell derivation remain separate; the original Desktop receipts are unchanged. + +Android and external contracts are unchanged. Beta 16/code 17 remains prepared from `2723845e`, +uninstalled; beta 10/code 11 is last installed. Phone testing remains paused at **10 Pass / +22 Partial / 32 Pending**. Repeated/page-wide GPU pressure, shared glyph eviction, synchronized/ +full-frame compositor or physical-display output, broader SSH reconnect, macOS/Windows and the remaining +Pixel checklist are unverified. Live +A105/A106 needs usable provider authentication; A25/A93 belong to the hosted-backend maintainers. +A68 is source-fixed for branch readiness; no PR has been opened. + +**A68 workflow readiness (2026-10-05).** Source fix `7e91785c` limits Android +push runs to `main` and retains pull-request path filters, without `merge_group`. Feature-branch +pushes no longer duplicate PR checks or automatically prepare beta inputs. Manual +`workflow_dispatch` with `prepare_beta=true` runs all five Android jobs; beta version overrides +and **Private beta checks** are confined to that opt-in. The registered workflow `366764274` +accepts API/CLI dispatch on the selected ref; verify the run's exact head before using its results. +Pre-cutover control [run `37260849252`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37260849252) +was dispatched at `c371860f` with `prepare_beta=true`: all five jobs passed. +That control establishes dispatch admission, not verification of the later A68 source fix. +All 13 focused trigger/path methods pass. All eight actual workflow mutations fail the real +configuration test, with all 13 methods passing before and after restoration. The full +949-method/98-suite protocol gate and offline app compilation are required before push. +After push, exact-head CI is required through `workflow_dispatch` with `prepare_beta=true`; +the verified run and exact head are recorded with the source exports. This closes the source +finding; no PR is opened by this checkpoint. APK/client bytes, private signing, phone pause +and **10 Pass / 22 Partial / 32 Pending** are unchanged. + +## Pixel 7a beta-16 check and A124 host repair (2026-10-05) + +A fresh beta 16/code 17 installation on the separate Pixel 7a passes installed APK hash, +signature and non-debuggable checks. Normal manual explicit-profile, high-port SSH and bounded +existing-terminal input/cwd/Unicode history/held-drag/coast/new-touch-stop checks pass. +The original `15dd1341` **New session → Start** attempt retained default Claude because its +nonclickable label did not select the shell; shared shell validation exposed A124 before CLI lookup. +The repaired wrong-label attempt's unavailable-Claude-CLI refusal is expected, not a new finding. +Actual shell-radio selection on frozen Desktop `40731381` creates and registers `/bin/bash`. +Phone input executes once in the expected cwd; viewer reopen and an Android-process restart with +saved SSH reconnect retain the same pane/marker and leave the other original terminal untouched. + +Host-retained Find locates two offscreen matching lines in 155 retained lines after 150 additional +output lines; native visible-screen capture independently excludes the old marker. History-line +clipboard paste matches. Local Copy-sheet search correctly has no offscreen-marker match; its +public URL offer and URL clipboard roundtrip pass. External browser opening is unverified. +Both QA profiles are removed through the app, which returns to the initial Computers/Add screen; +beta 16 and global identity remain. The repaired V5 host stops cleanly: app/sshd exit, closed +listeners and generated private-auth cleanup pass. The earlier host's source-binding failure stays +historical and separate. + +At `40731381`, offline protocol passes **949 methods / 98 suites** with zero failures/errors/skips; +38 affected Vitest tests, full TypeScript and three assertion-caught isolated guard mutants pass +(five platform Vitest cases skip on Linux). The frozen full Desktop build passes in 29.3 seconds. +First-push [Android run `37375870878`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37375870878) +passes all five jobs against exact head `40731381c646ccd3425af597b116b5371daf0a63`. +See [the verification notes](android.md#what-is-verified-and-how) and [audit A124](android-audit-2026-09.md#a124) for precise evidence and scope. Private physical proof: +`.nodeterm/android-pixel7a-device-y0ia4hm_/physical-repair-result.json`. +The original Pixel 10 Pro beta-10 ledger stays paused at **10 Pass / 22 Partial / 32 Pending**. +No wire/file contract changes; @eneskirca can note the shared host benefit for iOS. + +Next: continue the remaining background/lifecycle/outage, notification/answer/permission and +live-provider device matrix using fresh owned fixtures. Single-case success does not verify +managed End, power loss, original Pixel update/migration, QR/relay/cellular routes, FPS or all +64 checklist items. A25/A93 backend dependencies and other-platform checks remain separate. + +## Native custom wheel checks (2026-10-06) + +A86 adds one real SSH/tmux regression covering both copy key modes, asymmetric host wheel gains, +distance across 20-notch chunks and FIFO-sensitive queued reversals. The eight native positions +settle for 300 ms; original mouse/mode overrides and wheel bindings are restored. Targeted control +and isolated control/restored runs pass. Three transport mutations fail the exact distance assertion; +the earlier idle-copy-mode setup failure remains separate and is not a product finding. +See [the native receipt and exact positions](android.md#native-custom-wheel-checks-2026-10-06). +Only test/docs change; APK, production transport and iOS wire/file contracts are unchanged. +The Pixel 7a is offline, so remaining phone custom-binding/lifecycle/FPS and release checks still +need a reachable device. The original phone stays paused at **10 Pass / 22 Partial / 32 Pending**. +Before push, run both required offline gates; after push, verify all five Android jobs against the +exact head. Continue physical checks when the intended phone returns; A25/A93 remain backend work. + +## Desktop card reconnect repair (2026-10-06) + +A125 is source-fixed in `415dae9b`; A126 is committed in `5b7286b3`. +A125 replaces only the lost open card viewer after the coordinator succeeds and passes the +owning project through both boards. The card stays open, retains persistKey/requireRemote and +never replays an initial, pending or agent launch command. Its 13 new cases/100 affected tests +and six assertion-caught mutants retain their separate control/restored evidence. +A126 chooses one live automatic responder per exact `api.pty`/actual session in this renderer, +while preserving genuine input/focus and deterministic promotion. Its 16 new cases comprise nine +installed-xterm checks, one fallback shim and six fake-rendering mounted Modal cases with real +helper/LocalTransport/coordinator. +**294 unique affected tests / 17 files** and full incremental TypeScript pass. **15 isolated +mutants (six A125 + nine A126)** are caught by assertions; each fix's control/restored runs pass. +Forced offline checks at `5b7286b3` execute **950 tests / 98 suites**, zero failures/errors/skips, +and both protocol/app Kotlin compile tasks. These are disposable QA hosts; no new APK or regular +Desktop installation is included. Publication requires fresh offline gates and all five Android +CI jobs for the exact published HEAD; its private bundle and final report carry that run's result. + +Eight actual Linux Desktop **Canvas/Modal × soft/hard wraps × inner none/SGR mouse** cases +pass at `5b7286b3`. Each loses one birth-pinned product ControlMaster, creates a new master/ +selected local viewer and retains its remote pane/server/producer/cwd and complete same-grid history before, +during, after and at final observation. Both trusted actual textarea `g` inputs are acknowledged +on the old/new PTY: **16 ACKs**, **zero unexpected bytes**, one producer paint per case and +**16 complete URL activations**. Native hover/leave and exact owned cleanup pass. Modal cases +use `80x24`; Canvas cases use `49x48`, with each history comparison bound to its own grid/case. +Private native aggregate: `~/.cache/nodeterm-android-work/a125-a126-native-matrix-ww8e8nwo/root-acceptance.json`, +SHA `d8a5366b83d4c4b3ada9a1112521f762905ce3b835be4a32983ce9150f4f391a`. +It binds all eight receipt/runtime hashes, geometry/history/timing and exact cleanup. + +The historical `11e22453` stale-Modal negative and first `415dae9b` control retain their original +provenance. The latter creates replacement viewers but records 18 unexpected DA1/DA2 bytes after +ACK 1; ACK 2 is not attempted. Its unclassified wrapper/runtime exits 1/2 remain separate from the +later A126 diagnosis. Those failed controls are not rewritten as repaired-source positives. +See [audit A125](android-audit-2026-09.md#a125) and [audit A126](android-audit-2026-09.md#a126) for exact evidence. + +**Two-host follow-up (2026-10-06).** One Linux Desktop soft/none case at `59e4c93e` passes in +27.39 seconds: an ordinary quiet B park/adopt cycle, then inactive B's continuously open global +card reconnects while A remains active and unchanged. Three native `g` ACKs, two complete URL +activations, zero unexpected input, one paint per role, same-grid retained history and exact owned +cleanup pass. Carried SID provenance is primary create/native Canvas `g1` plus unchanged writer/ +subscriptions/client; no adopted Canvas-input/current SID-ref result is claimed. See [the bounded two-host follow-up](android.md#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06) +for exact source/proof hashes and the remaining scope; no new repair or product test count is added. + +Actual Canvas mounting and selected-view native keyboard recovery pass in the eight-case matrix; +one additional two-host soft/none case verifies a quiet park/adopt cycle and inactive global-card +recovery with the active host unchanged. See [the bounded two-host follow-up](android.md#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06). Remaining Android release acceptance needs a +reachable intended phone for lifecycle/outage, answer/notification/permission and live-provider +checks; A25/A93 need hosted-backend maintainers. Further host coverage includes streaming/expiry/ +eviction while parked, repeated or broader outage/backoff, Server/cross-window response ownership, +GPU pressure/full-frame display output and macOS/Windows. The synthetic key-only hosts do not +establish real-user PAM, power-loss, agent-launch or ordinary Quit behavior. Unsupported private +xterm shapes warn and retain input but may duplicate replies; legacy onBinary is unchanged. +No Android wire/file or iOS client change is required. The original Pixel 10 Pro ledger remains +paused at **10 Pass / 22 Partial / 32 Pending**. diff --git a/docs/android.md b/docs/android.md new file mode 100644 index 0000000000..f6e6fdf9d9 --- /dev/null +++ b/docs/android.md @@ -0,0 +1,5081 @@ +# Android companion — design notes + +`android/` is the Android counterpart of the iOS companion (nodeterm-ios, a separate private repo). +It speaks the protocol the desktop serves to phones, with additive typed host verbs, mirror +fields and an owned SSH actions service documented below. This doc records what the app relies on, +where each fact comes from, and what is not done. + +**Upstream PR conflict integration (2026-10-08).** The Android contribution now incorporates +upstream `main` at `adde5e85` (v0.4.2), retaining the phone's typed host verbs, managed terminal +ownership, SSH-visible files and marker-framed v2 answers alongside upstream chat, role-scoped +pairing and session backends. Android also recognizes upstream Antigravity and matches its +`agy` launch/resume grammar without inventing approval flags. The merged managed hook uses revision 6: older revision-5 hooks +are not admitted for upstream unmarked structured answers, while legacy phone answers keep +their separate contract. The current merge has its own offline, desktop and CI checks; it does +not change the source binding of the prepared beta23 APK or the last verified installed +beta22/code23. Merged-source device acceptance remains pending, and the original +**10 Pass / 22 Partial / 32 Pending** ledger is unchanged. **iOS implications for @eneskirca:** +the upstream chat/role changes and new structured-answer revision gate are shared host behavior; +the existing Android/iOS phone verbs and v2 framing remain compatible. + +**Pending Send after remount (2026-10-08, A137 — published; beta23 prepared).** +Pending Send state and guidance belong to the retained entry, with exact button/IME admission and +uncertainty preserved until dismissal. Source `b9e4cbc7` passes focused66/9, thirteen intended mutants, +fresh1069/116/offline app and CI37740459063 all-five/ten-step. Retained-signer beta23/code24 is +prepared and reviewed with 46 available SDK36/R8/metadata checks; actual SDK37 verification is absent. +A read-only pre-install snapshot refused before any update dispatch. Installation and the controlled +pending-Send phone case remain unverified; beta22/code23/source `2ba0b142` is last verified installed. +The original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. See [the scoped finding](#pending-send-outcome-after-remount-2026-10-08-a137). + +**Beta22 remount follow-up (2026-10-08 — bounded physical pass).** On the Pixel 7a, +retained-signer beta22/code23 (`fa894d4b`, source `2ba0b142`) keeps the exact unsent draft and +checked Ctrl through one normal locally rejected Pair screen and Back. Both original Bash panes, +server/socket, captures and desktop clients stay unchanged; the phone client reattaches under its +same SSH parent/session/TTY. No Send or producer runs. Source `2ba0b142` passes **1054/114**, offline +app compilation and CI37732792493 all-five/ten-step. In-place update preserves measured install +time and full runtime-permission blocks. Owned profile/auth/reverse/fixture cleanup is verified; +raw-input Ctrl timing, selection/IME and pending-Send checks remain unverified. The original +**10 Pass / 22 Partial / 32 Pending** ledger and installed artifact source stay unchanged. + +**Retained terminal editor (2026-10-08, A136 — source fixed; bounded fixed-phone pass).** +The Pixel 7a/beta21 loses its unsent draft and armed Ctrl after another screen covers the terminal +and Back returns. The fix keeps the full editor and Ctrl state with that live navigation entry. +Nine store and three wiring regressions pass; seven policy mutants and one wiring mutant are +caught. Beta22/code23 is published and installed; one retained-entry remount now passes. The +original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged; see +[the scoped evidence](#retained-terminal-editor-2026-10-08-a136). + +**Captured Ctrl consumption (2026-10-08, A135 — source fixed).** Delayed raw-keyboard +consumption now checks the captured Ctrl revision as well as its viewer. Five policy and three +wiring regressions pass; three policy mutants and one wiring mutant are caught. Source publication +and beta22 installation pass; a physical scheduling reproduction remains pending. See +[the scoped evidence](#captured-ctrl-consumption-2026-10-08-a135). + +**Lost Send reply/newer draft (2026-10-08 — bounded physical pass).** A fresh Pixel 7a/beta21 +direct-SSH case loses the original reader's successful 22-byte Enter receipt before it can return +to userspace. The exact newer 146-character draft is observed with Send disabled before loss; +it remains afterward while the app reconnects. One original-TTY witness/ACK stays unique at a +late observation more than 120 seconds later, and automatic recovery attaches a new viewer to +the original Bash/server/socket/producer with the complete sibling guard unchanged. Observed +checkout `5a3013dd`, Desktop build `0d8594a6` and installed APK/source `ef4caec2` are distinct. +Owned cleanup and original 15000-ms timeout restoration pass. Android's internal result is not +observed; network blackhole, whole-host loss, remount/Ctrl during pending Send, native TLS/history +and the full device matrix remain unverified. The original **10/22/32** ledger stays unchanged. +See [the measured receipt loss](#lost-send-reply-and-newer-draft-2026-10-08). + +**Held Send reply/newer draft (2026-10-08 — bounded physical pass).** A fresh Pixel 7a/direct-SSH +case observes the exact newer 146-character draft with Send disabled before a measured +9.000280-second post-execution receipt hold ends; after release the same draft remains with Send +enabled. One original-TTY witness/ACK stays unique 168.735781 seconds later, with the original +Bash/server/producer/socket/guard retained. Observed checkout `50e7a876`, Desktop build `0d8594a6` +and installed beta21/code22 APK/source `ef4caec2` are distinct; no product or APK change occurs. +Owned phone/host cleanup completes, with original timeout 15000 ms independently confirmed after +an uncertain restore, without replay. At that checkpoint, lost reply, network blackhole, +remount/Ctrl during pending Send and native TLS/history remained unverified; the original +**10/22/32** ledger is unchanged. +See [the measured receipt hold](#held-send-reply-and-newer-draft-2026-10-08). + +**Earlier nine-second Send/newer-draft follow-up (2026-10-08 — bounded partial).** A fresh `d361ce8f` +fixture uses unchanged Desktop/build `0d8594a6` and stock beta21/APK source `ef4caec2`. +One Send and a draft suffix return before the same SSH peer resumes, but the final newer-draft/ +disabled-Send XML observation misses the pause deadline; the original wrapper remains failed. +Later XML retains the exact newer draft and saved pixels show its suffix; Send is enabled and one +original-TTY witness/real ACK stays unique 125.310 seconds later. At that earlier checkpoint, the +strict timed race and lost-reply cases were unverified; the failed wrapper remains preserved. +See [the bounded follow-up](#nine-second-send-and-newer-draft-follow-up-2026-10-08). + +**Silent SSH peer recovery (2026-10-08, Pixel 7a / beta21 — bounded pass).** Fresh owned checkout +`04da9b9e` uses unchanged Desktop/build `0d8594a6` and installed APK/source `ef4caec2`. Only the +admitted old SSH peer is stopped for 45.000 seconds on shared loopback; automatic recovery creates +a new viewer while it is paused, preserving the original shell, producer, tmux server/socket and +guard. The unsent draft and its saved rendered text remain; one later explicit Send executes once +and receives a real ACK. At that checkpoint, whole-host/network loss, cursor position and +lost-reply races remained unverified. Owned cleanup passes; the original **10/22/32** ledger stays +unchanged. See +[the bounded receipt](#silent-ssh-peer-recovery-and-single-send-2026-10-08). + +**Native Desktop continuity (2026-10-08 — scoped verification).** A fresh private Desktop/build +`0d8594a6`, observed with the docs-only `98907171` checkout, passes two original session-host +factories, retained capture of all 200 unique numbered markers with Unicode, one normal renderer +reload and input to the same original shell before/after it. The complete sibling capture and +original daemon/Bash/producer/socket generations stay unchanged; owned retirement is verified. +Android native-history/Live-overlay acceptance, silent network loss and native-route held/lost replies remain open. +Installed beta21 and the original **10 Pass / 22 Partial / 32 Pending** ledger are unchanged. +See [the native consumer receipt](#native-desktop-capture-and-renderer-reattach-2026-10-08). + +**Login-shell deadline (2026-10-08, A134 — published; original create verified).** +Signed, pushed `3f382367`/`0d8594a6` pass forced **1033 methods / 110 suites**, offline app compilation, +full TypeScript and **161 tests in nine affected Vitest files**, with five known Windows-only skips. +[Run `37702852854`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37702852854), attempt 1, passes all five jobs and ten required steps. +A fresh source-built Desktop observes one original Linux session-host create fulfill and display +its Bash prompt, with zero PTY input. V13's child cause and whether fallback fired remain unknown; +native history and its phone overlay remain pending. Installed beta21/source `ef4caec2` and the +original **10 Pass / 22 Partial / 32 Pending** ledger are unchanged. +See [the scoped source checkpoint](#login-shell-probe-completion-deadline-2026-10-08-a134). + +**Direct SSH continuity (2026-10-08, Pixel 7a / beta21).** Desktop `0d8594a6` with the unchanged +stock APK/source `ef4caec2` passes controlled SSH-handler recovery, two active Home/resume cycles, +draft retention and one explicit Send to the original tmux pane. The producer/server/socket and +sibling guard remain unchanged. Phone cleanup is verified; the outer runner stop-accounting +negative is preserved. Native-history/relay/notification and wider lifecycle cases stay open. +See [the bounded continuity receipt](#direct-ssh-reconnect-and-active-background-continuity-2026-10-08). + +**Beta 21 published and installed (2026-10-07 — bounded closed SSH display verified).** +Signed, pushed `ef4caec2` passes forced **1033 methods / 110 suites**, zero failures/errors/skips, +offline app compilation and full TypeScript. [Android run `37678829862`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37678829862), +attempt 1, passes all five jobs and ten required steps. Retained-signer **beta 21/code 22** is +installed on Pixel 7a, APK SHA-256 `91638d05e6192794675dee6a045f53f19cdea707187ebb3482b8ae3f10320120`. +All **46 SDK 36 artifact checks** pass; actual SDK 37 tools are unavailable. The 5.083-second +beta-20→21 update preserves first-install time and the complete notification grant/flags; +the same owned SSH profile and session rows remain visible after the update. +A fresh owned direct-SSH producer completes all 1200 numbered rows and its confirmed 24-byte +completion write in **48.23 seconds**. Actual producer progress crosses Home/resume; later phone +screens show numbered/Unicode rows, the completion marker and older tmux history after a swipe. +The crossing uses receipt/filesystem order, not exact phone-monotonic latency. The original A132 +negative remains separate, with attribution open. A133's same-page exit-0 display retains eight +Unicode rows, wraps and the final tail; Copy-sheet filtering/selection, exact OSC8/plain URL offers +and font redraw pass. A fresh same-page follow-up copies the exact final marker and one Unicode +row through the normal clipboard into the ended composer; OSC8/plain link offers match after +smaller and restored fonts. At that copy/link checkpoint, browser navigation and Share were untested; wider lifecycle/TUI checks remain unverified. A129/A130 native-history acceptance and wider lifecycle/notification checks remain separate. The original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. +The preceding docs-only `9b8bba2b` checkpoint passes forced 1033/110/app/TypeScript and +[Android run `37690764287`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37690764287), +all five jobs and ten required steps. The build and installed APK remain bound to `ef4caec2`; +subsequent documentation publication requires fresh exact-revision gates and CI receipts. +The user authorized ongoing tests without a screen lock: ordinary wake and app launch are allowed, +with no PIN input or lock/security changes. The owned QA profile/public-key line/reverse are +removed, actual timeout readback confirms the original 15000 ms, and the exact disposable host +retires with listeners closed and private authentication removed. Beta21 remains installed. The regular Desktop is not deployed +and no PR is opened. + +**Fresh native diagnostics (2026-10-08; source unchanged).** The `9b8bba2b` normal-Fedora +40-ms run records all 1200 rows and its standalone marker; original A132 attribution stays open. +Linux session-host V8/V9 pilots stay blank before input; V9's 30-second gate/all-thread observations +still do not prove original creation or attachment. A129/A130 acceptance remains pending. See +[the scoped evidence](#fresh-fedora-output-and-native-desktop-readiness-2026-10-08). + +**Browser/Share pilot (2026-10-08; bounded live SSH).** Normal Copy Links Open reaches Vanadium's +exact nonce address and rendered content; Share previews only the selected marker and Back returns +to the owned terminal. Browser Back remains unverified; ordinary resume succeeds. Original +producer/guard continuity and exact owned cleanup pass; Open-focus and timeout uncertainties +are reconciled without replay. +No recipient, closed-page browser-lifecycle, wider matrix or ledger credit is claimed. + +**Earlier beta 20 publication and installation (2026-10-07, A131 — physical checks partial).** +Signed `11fbff08` passes forced **1029 methods / 108 suites**, zero failures/errors/skips, +offline app compilation and full TypeScript; [Android run `37659190980`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37659190980) +passes all five jobs and ten required steps. Retained-signer **beta 20/code 21** is installed on +Pixel 7a; the 4.875-second beta-19→20 update preserves first-install time and notification grant/flags. +A resumed direct-SSH screenshot contains later numbered rows than the earlier capture; active +production during backgrounding is unproven, so no background-delivery pass is claimed. The guard +shows only its own prompt. All 1200 numbered rows remain in native capture, but its completion marker is +absent there too ([A132](android-audit-2026-09.md#a132), attribution pending). After a separate +phone Send/exit, the terminal shows `[exited]` and an exit-0 overlay without the previously displayed +history ([A133](android-audit-2026-09.md#a133), cause pending); final-marker emission was not captured. +A131's focused **71/8** and **10 behavioral/four app source-pin mutants** remain source evidence, +not full lifecycle acceptance. Already-dispatched WebView JavaScript, A129/A130 native history, +wider lifecycle/notification cases and the original **10 Pass / 22 Partial / 32 Pending** ledger +remain outside these observations. See [the bounded receipts](#beta-20-publication-and-bounded-output-checks-2026-10-07). + +**Closed SSH/tmux display follow-up (A133 — published source repair; bounded physical checks pass).** +Native raw exit traces confirm `CSI 2 J` erases the visible alternate pane before `CSI ?1049l`. +This change retains that pane only at an admitted SSH/tmux EOF, as an inert same-page display with +Copy/links; live parsing, scrolling and transport capabilities stay unchanged. **27 shipped-xterm +component cases pass in the permanent behavioral JUnit regression**, alongside **three passing +app source-pin methods** and the existing swipe suite. The first full run caught a same-view delayed +Enter regression; layout changes now preserve it while viewer retirement still cancels it. Its +27-case mutation/control/restored checks pass. Earlier **11 semantic mutants** plus one geometry +mutant and three separate app source-pin mutants remain separately recorded below. Final +1033/110/app/TypeScript gates and all-five/ten-step CI pass at `ef4caec2`; beta 21/code 22 is +installed. Bounded same-page closed-screen, Copy-sheet, exact final-marker/Unicode clipboard +paste and post-font OSC8/plain link-offer checks pass. The later normal browser/Share pilot is +bounded as above; wider lifecycle/TUI checks remain unverified. The earlier beta 20 +came from `11fbff08`, with `656e5d8a` as its later docs-only head. A132's clean native +short/long-prompt controls pass, but the +original Fedora 40-ms/phone completion-marker case remains open. The original 64-row ledger stays +**10 Pass / 22 Partial / 32 Pending**. The equivalent iOS EOF/display behavior needs adoption review +by @eneskirca; no wire change is introduced. + +**Earlier beta 19 checkpoint (2026-10-07 — bounded direct-SSH checks pass).** +Signed, pushed `5bda2c32` includes the A130 held Live-touch fix. Forced fresh offline protocol +checks pass **1015 methods / 106 suites**, zero failures/errors/skips; offline app compilation +and full TypeScript pass. [Android run `37608652625`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37608652625) +passes all five jobs and ten required steps. Retained-signer **beta 19/code 20** is installed +on the intended Pixel 7a, SHA-256 `e7271ebe1c1d65d8f6a09f7c59ab4b0042081c152c421568caabdcf6965bad42`. +The 6.18-second beta-17→19 update preserves first-install time and the notification grant/flags; +actual SDK 36 artifact checks pass and SDK 37 tools are unavailable. Public SSH identity continuity +and a fresh explicit-profile first pin pass through an owned ADB reverse tunnel. Emacs/vi copy-mode +Send, brief background/resume draft retention, neutral viewer-close/manual Reattach and controlled +SSH-handler recovery pass on the disposable Linux host; that earlier owned cleanup passes. +Saved-profile/pin reuse and manual changed-key refusal now pass too; A129 native/session-host +history, A130 held Live touch and the wider lifecycle/provider matrix remain pending. The original +**10 Pass / 22 Partial / 32 Pending** ledger is unchanged. + +**A105/A106 Pixel 7a checkpoint (2026-10-07, Desktop `e06b5547` / beta 19).** Normal Always +allow applies one original `localSettings` rule; real sibling Claude makes two Bash calls without +another hold. One original two-question request submits Cobalt (single) and Lint/Docs (multi), +with real CLI tool/Stop/final-output confirmation. Saved-profile/pin reuse and changed-key refusal +pass, and both owned fixtures and the QA profile/key/reverse retire cleanly. Rule restart-persistence, +production-pane-child launch and the wider matrix remain pending. + +The fresh API-30 emulator boots initially but remains offline +after its one provisioning reboot; its bounded reconnect/screenshot/debugger diagnostics and +owned cleanup add no app/CA/APK acceptance. See [the scoped evidence](#beta-19-publication-provider-and-emulator-checkpoint-2026-10-07). + +**Earlier native history publication (2026-10-07, A129 — physical acceptance pending).** +Signed backend `0818bbed` and host/Android/producer/CI `2d693263` are included in the signed, +pushed integration `1add0408`. They implement +`pty.attach`'s `scrollV1` capability and `pty.scrollV1` for bounded, inert native/session-host +history. A valid viewer token continues paging its immutable snapshot despite live application +mouse-mode changes; Live, actual user input, resize or an explicit new intent clears that view. +A new intent checks the actual backend mode before any wheel input. Source publication and +mandatory verification pass; actual app/WebView, physical ConPTY and phone acceptance remain +unverified. + +Keep the proof layers separate: **34 emulator tests / 3 files + 20 assertion-caught mutants**; +**343 backend tests / 37 files + 21 assertion-caught mutants**, with four existing Windows-only +skips on Linux; **13 helper/RPC control/restored tests + 18 assertion-caught mutants**; and one +fixture-cleanup test with one assertion-caught mutant. Focused Android control/restored runs pass +**99 methods / 11 suites** and the offline app check. Its **14 assertion-caught variants** are +**13 behavioral mutations + one posted-Runnable source pin**. Separate CI-reader control/restored +checks pass **11 methods** and catch **eight configuration-input deletions**; these are not +additional production mutations. No aggregate combines these layers or earlier attempts. +The root helper/RPC receipt is `a129-history-view-proof-v4-67xv3v57/mutation-result.json`, SHA-256 +`fe62c4f299afa9a7254ddb8aaf8e052386e71f7742917565c90363a8fb025583`. +At exact `1add0408`, forced fresh offline protocol checks pass **1012 methods / 105 suites** +with zero failures/errors/skips; offline app compilation, **568 affected Vitest tests / 52 files** +with exactly four existing Windows process-tree skips, and full TypeScript pass. +[Android run `37602046133`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37602046133), +attempt 1, passes all five jobs and ten required steps. Private +`a129-publication-biru7yjl/gates.json`, `ci-result.json` and `final-report.json` bind this source. +The retained-signer **beta 18/code 19** APK is ready and **not installed on the intended phone**, SHA-256 +`19d55ee177335064b1edb4874b6d27216862d16a7d30ebe476875c566cd263e0`. Actual SDK 36 artifact +checks pass; SDK 37 artifact verification is unavailable. These are source/component/recorder +and browser-harness boundaries, not full Desktop/WebView or phone acceptance. The regular +Desktop is not deployed and no PR is opened. +The separate published A128 checkpoint `944223ef` has **984 protocol methods / 102 suites**, +**360 affected Vitest tests / 26 files** and all five jobs/ten required steps green in +[Android run `37594743169`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37594743169). +Its private `a128-publication-1vq7hcgc/gates.json` and `ci-result.json` retain that exact source +binding. At that earlier checkpoint, installed beta 17/code 18 remained from `a79375c3`; no new physical acceptance or change +to the original **10 Pass / 22 Partial / 32 Pending** ledger is claimed. + +**Held Live-button published follow-up (2026-10-07, A130 — app acceptance pending).** +Signed `9da36320`, included in published `5bda2c32`, stops old momentum and the native pending-scroll +queue on touch-down, preserving history and its display epoch until click. Three new LiveTouch +JVM methods pass within **48 methods / 7 suites** in control and restored runs, with zero +failures/errors/skips. **Three semantic mutants** (old handler, premature history close, parent +gesture) fail assertions. Exact-head final gates/CI pass and beta 19/code 20 is installed; +actual WebView/physical acceptance remains pending. The `1add0408` failure remains historical. + +**Composed Send backend follow-up (2026-10-07, A128).** Current source also supports the +attached direct native Windows PTY and a session host that negotiates `composed-input-v1`. +The host owns one-use tickets scoped to its live session generation and the original subscriber +socket; paste and Enter remain separate writes at least 150 ms apart. A lost receipt after input +may have been sent stays uncertain and never replays. Older live hosts refuse without being +restarted. The public Android `pty.submitComposed` request/result is unchanged; its actual +backend producers are tested through the relay and Kotlin client. Windows ConPTY and phone +acceptance of these additional backends remain unverified. At that earlier checkpoint, installed beta 17 still came +from `a79375c3`; that host-side follow-up did not represent a new installed APK. + +**Beta 17 Pixel 7a follow-up (2026-10-06, A127/A128).** Retained-signer beta 17/code 18 +from `a79375c3` is installed on the intended Pixel 7a (Android 17/API 37, +Vanadium `154.0.8037.126.0`) against a fresh disposable Desktop from the same source. +The 5.48-second same-signer update preserves the first-install time and saved temporary SSH +profile; Sessions reopens without editing its pin or readmitting the phone key. One emacs +copy-mode Send submits exactly once to the original producer, exits copy mode and leaves the +sibling guard unchanged. The A127 viewer-only exit now shows a neutral connection-close notice, +disables Send and retains the draft; manual Reattach preserves the same producer and complete +same-grid history, and the retained draft submits once. These are bounded repaired-device cases; +vi copy-mode recovery/Send also preserves the pane and full same-grid history, with the draft +retained and Send disabled during the outage, then one native submission after recovery. One +Ctrl+C action reaches the owned raw TTY consumer as exactly `03`, with no extra byte in a bounded +0.8-second window. Actual phone-managed shell creation and exact owned SSH End pass too: +the producer/pane/viewers end while the sibling guard and expected canvas node remain. +Only the temporary profile/public-key admission are retired; the nonce-validated host stop +removes generated private authentication and closes both owned listeners. Nine bounded cases +and 303 raw-artifact hashes are recorded in the frozen report; ordinary native Quit is unverified. + +At exact merged `a79375c3`, full forced offline protocol checks pass **982 methods / 102 suites**, +zero failures/errors/skips; offline app compilation, **187 affected Vitest tests / 11 files** +and full TypeScript pass. [Android run `37523703627`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37523703627) +passes all five jobs and ten required critical steps. The APK and installed public-code pull +both hash to `c51433d4…`; actual retained-signer verification uses SDK 36, with SDK 37 tools +unavailable. A127 is source-fixed in `0d50075a`; A128 in `7e7d2c04`, with beta CI registration +`5484ab9f` and **30 assertion-caught source/configuration mutations**. Older beta-16 failures +remain historical. This does not complete the 64-item checklist or the original Pixel 10 Pro +ledger, which stays **10 Pass / 22 Partial / 32 Pending**. See [the bounded beta-17 receipt](#pixel-7a-beta-17-upgrade-and-composed-send-2026-10-06). + +**Desktop SSH reconnect checkpoint (2026-10-06, A125/A126).** A125 is source-fixed in `415dae9b`; A126 is committed in `5b7286b3`. +Eight actual Linux Desktop **Canvas/Modal × soft/hard wraps × inner none/SGR mouse** cases +pass at `5b7286b3`. Each loses one birth-pinned product ControlMaster, creates a new master/ +selected local viewer and retains its remote pane/server/producer/cwd and complete same-grid history before, +during, after and at final observation. Both trusted actual textarea `g` inputs are acknowledged +on the old/new PTY: **16 ACKs**, **zero unexpected bytes**, one producer paint per case and +**16 complete URL activations**. Native hover/leave and exact owned cleanup pass. Modal cases +use `80x24`; Canvas cases use `49x48`, with each history comparison bound to its own grid/case. +**294 unique affected tests / 17 files** and full incremental TypeScript pass. **15 isolated +mutants (six A125 + nine A126)** are caught by assertions; each fix's control/restored runs pass. +Forced offline checks at `5b7286b3` execute **950 tests / 98 suites**, zero failures/errors/skips, +and both protocol/app Kotlin compile tasks. These are disposable QA hosts; no new APK or regular +Desktop installation is included. Publication requires fresh offline gates and all five Android +CI jobs for the exact published HEAD; its private bundle and final report carry that run's result. +A separate Linux two-host soft/none case passes at `59e4c93e`: one quiet park/adopt cycle, +inactive B global-card warm reconnect while A stays active and unchanged, **3 native ACKs / 2 +complete URL activations**, retained same-grid history and exact owned cleanup. See [the bounded two-host follow-up](#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06). +The earlier negative/unclassified controls remain historical. Phone release/backend checks still +need their own acceptance; wider parking, Server/cross-window, outage/platform and GPU coverage +remain separate. +Unsupported private shapes preserve input with a warning but may duplicate replies. Android +wire/file/iOS adoption is unchanged. The original Pixel 10 Pro ledger stays paused at +**10 Pass / 22 Partial / 32 Pending**. + +**Fresh-phone checkpoint (2026-10-05, A124).** A separate Pixel 7a has a fresh, +hash-verified, non-debuggable beta 16/code 17 installation (Android 16 / API 36, +Vanadium WebView `145.0.7632.120.0`). Manual explicit-profile, high-port SSH and the existing +terminal's input/cwd/Unicode history and bounded held-drag/coast/new-touch-stop checks pass. +The original **New session → Start** attempt kept default Claude selected and failed shared +shell validation on host `15dd1341`. A124 (`651f46da`) fixes absolute executable resolution. +**The repaired single managed-shell case passes physically on Desktop `40731381`:** actual shell +radio selection, canvas registration, input exactly once, viewer reopen and saved SSH reconnect +after an Android process restart all preserve the same pane and the other original terminal. +Offscreen retained-history Find, selected-line clipboard paste and Copy-sheet URL clipboard +roundtrip also pass. Both disposable QA host profiles were removed through the app; beta 16 and +the phone identity remain. The repaired host stopped cleanly with generated authentication removed +and listeners closed. The original Pixel 10 Pro beta-10 checklist stays paused at +**10 Pass / 22 Partial / 32 Pending**. Fresh-install/single-case results do not verify its update, +live agents, managed End, power loss, QR/relay/cellular routes or the full device matrix. +Dated receipts below remain historical. + +At checkpoint `40731381`, offline protocol tests pass **949 methods / 98 suites**, with zero +failures, errors or skips; **38 affected Vitest tests** pass with five platform skips, full +TypeScript passes, and all three isolated guard mutants fail their expected assertions. +The frozen full Desktop build passes in **29.3 seconds**. The first push has all five Android jobs +green in [run `37375870878`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37375870878), +verified against exact head `40731381c646ccd3425af597b116b5371daf0a63`. Later pushes need their own gates and CI. + +**Native custom-wheel checkpoint (2026-10-06, A86).** A real SSH/tmux regression now verifies +custom copy-mode bindings in both emacs and vi modes, distance across 20-notch chunks and +FIFO-sensitive reversals. All eight settled native positions pass; three deliberately broken +transport variants fail the expected distance assertion. The later Pixel 7a follow-up adds bounded +custom-binding and lifecycle cases; wider gesture/reversal, lifecycle and FPS coverage remains +open. See [the native checks](#native-custom-wheel-checks-2026-10-06) and the physical receipt above. + +**Current source checkpoint (2026-10-05, A100–A124).** The branch adds retained terminal history search, +trusted project env/shell and per-agent launch policy, offscreen Sleeping wake, remembered hook +rules and complete held Claude questions. Direct SSH supports Source Control, selected-profile +Board writes and Desktop wake/refresh/rename. New session can now ask a current Desktop/Server +with an enabled Linux/macOS tmux backend to create and register a managed shell or agent in an +open local folder project (A111); the host resolves its command, account, environment and hooks. +Older hosts retain the relay New flow. The actual producer fixture also exposed and fixed Android CI/local incremental coverage for `src/session-host/**` (A112). Current source also adds truthful legacy revoke outcomes (A113), a dictation language picker (A114), exact connection/record retirement (A115) and a saved LAN/VPN adapter choice (A116). This prepares the upstream Android contribution; no PR +has been opened. Beta 16/code 17 was prepared from `2723845e`, passed the local release checks and is now installed on the separate Pixel 7a. It includes A118's eligible legacy relay identity proof, an explicit SSH profile folder (A119) and one-time password enrollment with fingerprint confirmation and retained-key verification (A120). Current host source also fixes direct-SSH coattachment (A13) and protects relative private HostKey Include reads (A117). +A120 also passes controlled native OpenSSH enrollment, cancellation/deadline and synthetic installed-Fedora-PAM checks; see the later verification receipts. +Current Desktop source also fixes focused-terminal native Quit interception (A122, `9613cec5`); all four Linux native focused-terminal cases pass. +Later checks verify eight shipped-tmux and eight actual SSH-project cases. Separate GPU recovery +pilots exposed and verified A123 restored-context cleanup (`db83fb7d`). A later genuine Server +pilot verifies the same retirement path at `fa259f6e`; separate derived Desktop cell comparisons +use the existing `db83fb7d` captures. A single Desktop Canvas SSH reconnect pilot also passes +at `fa259f6e`; see the final follow-up for their limits. +The original Pixel 10 Pro remains paused on beta 10/code 11 with +**10 Pass / 22 Partial / 32 Pending**. Fresh Pixel 7a results and the A124 failure are recorded +separately above; the repaired single managed-shell case passes, while live-provider and broader lifecycle/outage checks remain unverified. Immediate FCM +and fresh-different-desktop relay recovery (A25/A93) still need the maintainers' hosted backend +source; the user has no backend checkout. Existing missing canvas sessions remain attach-only +over SSH. See the audit and Known gaps for unsupported backends and remaining verification. + +**Post-beta-10 additions (2026-10-04).** Sessions search filters displayed names, agent labels, +project names/folders and phone-terminal folders while keeping the existing project/status groups. +The saved query returns after opening a terminal. All computers now watches each paired host while +visible, with automatic approval holds intact and serialized refreshes. The beta-11 Find action opened the captured +output Copy sheet: literal case-insensitive matches, UTF-16 highlighting and wrapping Previous/Next +navigation preserve the original rows and copy selection. It searches the captured output, not the +remote tmux history. SSH anchor discovery follows bounded recursive configuration Includes, including +quoted paths, multiple patterns and relative paths rooted under `/etc/ssh`; relative HostKey paths +remain unsupported. These are included in prepared private beta 11; the installed beta 10 +and its 10 Pass / 22 Partial / 32 Pending device ledger are unchanged while phone checks are paused. +Additional physical checks belong beside items 10/11/50/56/60; they do not inherit earlier Pass results. + +**Historical prepared private beta 11 (2026-10-04).** `0.1.0-beta.11` / code `12`, clean built source +`314105a435d18e719f53d49bb292d0f3e965ad4a`, includes all four additions (`A96`–`A99`) and the +A95 desktop Board fix. Full offline protocol checks pass **730 tests / 70 suites**, with zero +failures, errors or skips; the offline app compile, **297 affected Vitest tests / 17 files** and +full TypeScript checking pass. The four feature regressions catch **81 isolated mutants**. +The actual offline minified release build passes in **51.78 seconds**, retains the private signer, +and passes the R8/runtime keep and packaging checks. Independent SDK 36/37 signature, +nondebuggable-manifest, payload/provenance and 16-KB ZIP/native checks also pass. The existing +New-session-note layout check passes its control/restored run and catches four additional mutants. APK SHA-256: +`ae052a8a02567c42576012309c5de059827d8b9ff28a9972673af0440d977341`. Artifact: +`.nodeterm/android-beta-11/nodeterm-android-0.1.0-beta.11.apk`; build/regression proof: +`.nodeterm/android-beta-build-11/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed phone installation. Phone checks and final fixture/rotation cleanup stay paused; +10 Pass / 22 Partial / 32 Pending remains the device ledger. These source/build checks do not +verify the new touch/keyboard, multi-host freshness or included-key pairing flows on the Pixel. + +## The two transports + +A phone reaches a paired computer one of two ways, and the app tries them in the iOS order: + +1. **Direct SSH** (the LAN leg). Pairing installs the phone's Ed25519 public key in the computer's + `~/.ssh/authorized_keys` (`src/main/pairing-service.ts`). Everything after that is POSIX `sh` + + tmux on the computer (`protocol/.../ssh/SshScripts.kt`): the `-L node-terminal` socket of a + nodeterm running on the computer, and the `-L nodeterm-rmt` socket of a desktop that drives the + computer over SSH (audit `A27`, below), each session reached on the socket it was listed on; + `attach-session` **without `-d`** (the desktop's own client must stay attached), never + `new-session` for an existing canvas or agent node (`A08`). PATH is *appended* with the Homebrew + dirs, exactly like `remoteTmuxPathPrologue`, and the macOS app's bundled `Contents/Resources/bin/tmux` is the last + resort, as it is for the desktop's `findTmux`. Not available on Windows hosts (the QR says + `"ssh":false`). A computer with no pairing code can be added by its SSH address instead, and is + then reached this way only (audit `A27`, below). +2. **The relay** (from anywhere). E2EE through `wss://relay.nodeterm.dev`, to the standing phone + host (`src/main/remote/standing-host.ts`). The phone trades its device token for a single-use + relay token (`POST /v1/relay/join`), runs the handshake, and — the first time only — waits while + the desktop shows the SAS approval dialog (pin-once). + +**Explicit plain SSH terminals (`A90`).** The user needs a new shell on their manual +SSH/WireGuard host, which another desktop drives and which has no local nodeterm workspace. The +new flow creates a phone-owned plain shell on a separate `nodeterm-phone` socket in a chosen +discovered host folder or Home. Session metadata lists it under **Phone terminals**; it survives +disconnect and can be reopened or ended by its exact owned session. It does not write +`project.json`, register a desktop canvas node or borrow a managed agent identity. Desktop and +Server Edition behavior is unchanged; their socket scans/reapers remain limited to their own +`node-terminal` / `nodeterm-rmt` sessions. This explicit creation path keeps the `A08` refusal to +create a missing canvas/agent session and the relay New-session flow. Implementation and host +regressions are verified; private beta 10 / code 11 is now installed on the intended Pixel. Earlier +beta-6/code-7 device evidence remains historical, and beta 7 / code 8 was prepared but unused. +The existing SSH host reconnects and lists its driven projects; New terminal is visible and +enabled. Focused Home/project/custom creation/history/reconnect/exact End now pass on the +Pixel; item 32 is Partial: beta 10 relay plain-shell creation/input/End also pass, while managed and cellular creation remain pending; A91 empty-host variant passes. No current +RPC/blob/pairing/mirror/SSH-visible file contract changes; +@eneskirca can adopt the isolated socket and its creation marker/session metadata for iOS. + +On a Linux SSH host, use **Sessions → New terminal**, choose **Home folder**, a discovered project +folder or a custom absolute folder, then **Create**. The shell appears under **Phone terminals**. +Close the viewer or app to leave it running; reopen its row to return. Use that row's +**End session…** to stop only its owned session. These shells are independent of the desktop +canvas and managed agents. + +`Auto` tries SSH with a 4 s budget and falls back to the relay. Per-computer overrides live in +Settings ("How to reach each computer"). + +**SSH host key.** Anchored in the pairing (audit `A49-anchor`): the desktop's sealed `/pair` answer +names its SSH host keys (`sshHostKeyFingerprints`, the `SHA256:…` of every +`/etc/ssh/ssh_host_*_key.pub` and of the keys `sshd_config` and every file in `sshd_config.d/` +name, read by `src/main/ssh-host-keys.ts`; macOS 10.10 and older kept them in `/etc`). The phone stores them on +the paired computer (`PairedHost.sshHostKeyAnchors`), and its first SSH connect must present one of +them: a server whose key is none of them is refused during the key exchange, before the phone's key +is offered, and nothing is pinned (`HostKeyNotPairedException`). The keys ride only the SEALED +answer, which is bound to the host key the QR on the computer's screen carries; a plaintext answer +could be rewritten on the LAN, so the desktop leaves them out of it and the phone ignores them +there, and the QR does not carry them. A desktop that predates the field, a Windows desktop (no SSH +leg) and one that could not read its keys send none, and the first connect is then trust on first +use as before. Either way the pin is saved only once the server has accepted the phone's key (audit +`A49`): a machine that merely answers at the paired address and refuses us never becomes the pin. A +server that presents a host certificate (sshd's `HostCertificate`, which sshj negotiates whenever it is +offered) is matched and pinned by the key the certificate certifies (`hostKeyFingerprint`), which is how +OpenSSH fingerprints a certificate and what the desktop reports, so a certificate reissued for the same +key is not a changed key; a pin an older build took from the certificate itself still matches it and +is rewritten as the key at the next connect (review of `A74-refresh`). A key that differs from the +pin (or, before the first pin, from every key the pairing named) is never used over SSH. In `Auto` +the connect then goes on to the relay (`SshFallback`, audit `A74`), which authenticates the computer +on its own (the relay host key from pairing, then the SAS approval), and the host screen keeps a +warning up while connected that way; the relay dial still goes through +`RelayApprovalGate`, so a background check never makes a first relay handshake because of it. "Only +on my network" stops with the warning. The usual cause is benign: the LAN leg dials the DHCP address +the computer last reported (at pairing, or since through the relay, below), and another SSH-running +machine now has it (or the phone is on another network using the same range). The message points at "Only through the relay". A reinstalled +computer's new key is trusted again by the refresh below, or by pairing again (for a computer added +by its SSH address, by forgetting it and adding it again). A key the computer never reported +(`HostKeyNotPairedException`) takes the same route but not that promise +(`SshFallback.NOT_REPORTED_NOTE`): pairing again and the refresh both re-read the computer's keys the +same way, so they help only when those keys changed, and an SSH server using a key the reader cannot +see stays refused on the network (see Known gaps). The iOS app can adopt the same field: it +is additive in the sealed answer, and a phone that does not read it pairs exactly as before (a +follow-up for @eneskirca in nodeterm-ios). + +**The LAN leg is refreshed over the relay** (audit `A74-refresh`). The address the LAN leg dials and +the keys it checks were both facts about the moment of pairing: the QR's `host` is a DHCP lease, and +a reinstall regenerates sshd's keys. So every relay `projects.list` answer carries, beside the blob, +what the computer says about its own SSH leg now: `{ output, lan: { host?, sshHostKeyFingerprints? } }` +(`src/main/remote/host-lan-report.ts`: the same `pickLanIPv4` the QR uses and the same host-key +reader as the sealed `/pair` answer; the keys are re-read at most once a minute, the address on every +answer; never on Windows, which has no SSH leg). The phone updates the paired computer from it +(`LanRefresh`, applied by `HostSession` after the primary relay connection's listings): a different +address replaces `PairedHost.host`, and the reported keys become the anchors. The pin gives way only to +a key the computer CONFIRMS (review of `A74-refresh`): when the SSH leg of the connect that opened this +relay connection was refused a host key (`HostKeyChangedException.actual`, which `SshFallback` hands on +as `TryRelay.refusedHostKey`) and that key is among the reported ones, the pin is dropped, so the next +SSH connect must present one of the computer's current keys and pins it once it has authenticated. +That covers a reinstall (new keys) and an sshd that stopped serving the pinned key while its `.pub` +stays on disk, so the computer still reports it. A pin is never dropped merely for being missing from +the report: the report is what nodeterm on the computer could read of its sshd's keys, not what sshd +serves, and a pin the phone has not seen fail is one that works. No reported keys (an older desktop, +keys it could not read) leave the pin and the anchors alone. The cost of asking for a refusal first: a +computer reinstalled while the phone was away is refused once more at its next connect on the network, +which goes on to the relay, and that relay connection confirms the key for the connect after it. This +is safe because the relay authenticates the computer on its own (end-to-end encrypted to the box key +pinned at pairing, served only once approved). +Nothing that came over SSH ever refreshes these facts (`LanRefresh.afterListing` refuses an SSH +listing, and an SSH listing never carries the field), and neither does the relay held next to a live +SSH connection (`viaRelay`), where the LAN leg as recorded has just authenticated. While the host +screen shows a changed-key warning, a refresh that moved the address or confirmed the refused key adds +a sentence saying so. The field sits beside the blob, not inside it, so the blob stays the shape a phone +on direct SSH reads off the host, and an iOS app that does not read `lan` sees the reply it always saw +(a follow-up for @eneskirca in nodeterm-ios). + +**Relay approval.** The standing host raises its SAS dialog as soon as an unpinned phone completes +the handshake, so the phone decides *before dialing* (`RelayApprovalGate`): the background worker +dials only a computer that approves this phone without its dialog (a relay connect has succeeded, or +pairing answered `relayApproved`, below), and a refused or unanswered approval suspends automatic +dials until the user asks again. A current desktop pins the phone's relay key at pairing +(the phone sends `boxPublicKey` inside the sealed `/pair` body; the answer says `relayPinned`), so +most phones never see the dialog. A pairing with no relay leg (remote access off at the scan, or a +failed mint) records the key on the device entry in `agent.json` without pinning it, and the +standing host pins it on the phone's first relay handshake while that pairing is listed (audit +`A07-late`, `approvePairedRelayKey`): the phone that adopts the relay later over SSH (`relay.json`, +below) is not met by a dialog at a desk it has left. Either way the answer says `relayApproved`, which +is what the phone stores as approved, so its background check may use a relay it adopts later. Not +pinning at the scan keeps a LAN-only phone out of the pin store, which host-mode push reads as "a +relay phone is paired". The late pin's "still paired?" check runs inside the pin store's queue, so a +revoke racing a handshake cannot leave the key pinned. While the standing host decides on its own +(its pin store, then the late pin), `connectHostSession` holds the phone's requests instead of +answering "Awaiting host approval.", which a background check reads as an approval it needs: they are +answered once the host has approved the phone or raised its dialog (`PEER_DECISION_HOLD_MS`, 5 s at +most). No new file is involved: the key is the one +the scan already authorized, and someone who could edit `agent.json` has a shell as the user, which +could edit the pin store just as well. A confirmed revoke of a recorded last key drops its pin and closes the phone's relay sessions. A113 reports missing association/revoker as unconfirmed and a key authorized by another pairing as retained; removing a local entry alone proves neither cut. The phone then redials (about 1.5 s after a drop while its screen +is open), and for the rest of that desktop run such a handshake raises no SAS dialog: the standing +host leaves it unapproved, so the phone hears "Awaiting host approval.", and closes it a few seconds +later (`REVOKED_PHONE_DENY_MS`), which the phone reads as a refusal and stops dialing on its own, as +after Deny. Pairing the phone again lets it back in (the pin or the recorded key is checked first); +after a desktop restart a dial from it shows the dialog again, as for any unpinned phone. If the +desktop cannot write the unpin, the device stays listed and Settings → Phone says to try again, +because the surviving pin would let the phone back in without a dialog. + +**What opening an existing node over direct SSH will not do.** It never creates a missing canvas +or agent tmux session (the desktop injects the hook environment at creation, which the phone +cannot reproduce) and never touches nodes of the desktop's +SSH projects (they live on another host). Both surface as `NeedsRelayException`, and the app opens +the session through a relay connection held next to the SSH one (`HostSession.viaRelay`). + +**The relay leg next to SSH (audit `A26`).** `Auto` keeps SSH as the primary connection when it +works. Direct Git runs on admitted folders without a relay (`A107`). A current selected-profile +SSH service handles Board writes (`projects.ensureBoard|setCardColumn|editCardLabels`) on Desktop +and Server, and delivery-only node nudges (`node.wake|refresh|rename`) on Desktop (`A108`). +A canvas-registered new session (`projects.registerNode`, and the attach that creates it) still +needs the relay. For a capability the primary connection does not serve, +`HostSession.connectionFor` opens an allowed relay leg on that tap and keeps it until the +connection is dropped. Which leg answers is ONE pure decision, +`LegRouting.route` (`android/protocol`, `LegRoutingTest`): the primary connection when its +capabilities include the verb, else the relay leg when the phone holds one (a relay block and a +stored device token — often minted while on SSH by late adoption — and a route other than "Only on +my network"), else unavailable with a reason. Holding a token is not enough on its own: it outlives +the desktop's remote-access toggle, so every listing over SSH also reads whether the computer +advertises its relay right now (`relay=1|0` in the browse script's meta block: is +`~/.nodeterm/relay.json` there, which the desktop writes while its phone host is registered at the +relay and removes when it stops). A token with no advertisement is "remote access is off on the +computer" (`RelayLeg.REMOTE_ACCESS_OFF`), not a tap that waits out the relay's 20 s handshake; an +advertisement with no token yet is picked up by late adoption on the user's refresh, or by the +8 s refresh as soon as it appears (`LegRouting.adoptAfterListing`), and says so meanwhile instead +of "turn on remote access". The token is asked by presence (`SecureStore.hasRelayToken`, no Keystore +decrypt), because the screens ask the routing while composing (`A47`). The screens ask the same +function, so an unavailable control is shown **disabled with that reason** (canvas New session, whose +reason is the Sessions list's first row; the board's card actions; the session menu's +wake/refresh/rename), never hidden; the Source control screen (`A29`) says it in place of the +repository. The relay dial still goes through `RelayApprovalGate` with the caller's trigger: these +are taps (`Trigger.USER`), so the first one on a desktop that has not pinned the phone shows the +approval code on the screen that asked (the host screen, or Source control), and a background path +never makes a first handshake that would raise the dialog (its only first handshakes are with a +computer whose pairing answered `relayApproved`, above). Answering approvals, read-acks, typing keys and ending a session stay +on SSH. + +**A computer a desktop drives over SSH, and the Server Edition (audit `A27`, part a).** The SSH +browse reads more than the paired desktop's own files. It finds the data dir of a nodeterm running +on the computer in this order: the desktop app's (`~/Library/Application Support/node-terminal`, +`$XDG_CONFIG_HOME/node-terminal`, then the legacy `nodeterm` spelling), then the Server Edition's +(`$NODETERM_DATA_DIR` when the SSH session carries it, then `~/.nodeterm-server`, the default in +`src/server/config.ts`; a fresh install that has written only `install-meta.json` counts). A server +started with `--data-dir` elsewhere can be selected with **Profile folder** in Add SSH server or +**SSH profile folder** under Settings → How to reach each computer (A119). The saved absolute path +overrides discovery; a missing explicit folder reports an error without falling back. Leave the +field blank to restore discovery. A computer with nothing found reads as "not +found, here is where the phone looked", never as an empty computer; it ends by offering the relay +only when the phone has a relay leg for the computer (`NothingFoundException.said`), and for a +computer added by its SSH address it suggests checking the user instead. "Nothing found" is judged +on what the listing can use: the desktop never deletes a status slice, so on a computer a desktop +drove once the old ones stay, and a slice that is no data (stale, unreadable, misnamed) counts as +nothing. +It also lists the `nt-*` sessions on `nodeterm-rmt`, where a desktop ELSEWHERE runs the sessions of +its SSH projects, and reads what that desktop leaves on this computer, since there is no +`workspace.json` for those projects here: + +- each project's canvas, `/.nodeterm/project.json`, found by walking up from each + `nodeterm-rmt` session's start directory (`#{session_path}`, the node's cwd the desktop gave + `new-session -c`); the SSH mirror writes it with the desktop's project id as `id`; +- each status slice, `~/.nodeterm/agent-status-.json` + (`src/main/remote-ssh/remote-status-push.ts`). The desktop re-flushes it at least every + `STATUS_HEARTBEAT_MS` (60 s) while connected, so a slice whose `updatedAt` is more than twice that + old is **no data**: its states and Inbox cards are dropped, not shown as current. The comparison + uses the phone's clock against the desktop's; +- the sessions themselves. One that no project names is still listed, under "Other sessions on this + computer". + +These become projects marked `drivenRemotely` (`HostBrowse`, `android/protocol`), after the host's +own. The host's own index wins: a project id or node it already lists is never listed twice. +Terminal access to the paired desktop's OWN SSH projects stays relay-routed (`A09`), even when +that desktop drives this very computer. What the machine does works on a driven project's sessions over SSH, on +their own socket: attach (attach-only: a session the driving desktop creates there gets its remote +tmux.conf and hook env, which the phone cannot give it), keys, the wake line, held approvals (a file +on this computer, where that desktop's SSH answer path looks), read-acks (written where that +desktop's ack sweep looks; on a computer that also runs its own nodeterm, see Known gaps), and ending +the tmux session. Direct Git also works in a listed driven project's folder on this computer +(`A107`). Its canvas registration, Board and node actions belong to the driving desktop, which +neither leg of this computer reaches; `LegRouting.forProject` makes those unavailable with that +reason. The selected-profile SSH service does not adopt another desktop's driven projects. +A driven session that is not running says it starts from that desktop instead of offering this +computer's relay. The relay is +offered for a session that is not running only when it is a node of the computer's OWN index: the +relay's `pty.attach` creates what it does not find, so for any other node (a driven one, or one no +listing names, such as a driven project no longer listed or a deleted node) it would make a bare +`nt-` of no project on `node-terminal`, which later attaches would then find first. Which nodes +are whose comes from a listing, so the first node-scoped call on a connection that has not listed +yet (a redial, or a terminal restored after the process died) lists first. The status block +merges the host's own mirror with the fresh slices (the host's entries, settings, usage and `server` +block win). The Server Edition's `server` block (version, commit, install date) is shown on the +host screen, above its tabs. A computer that has no pairing code to scan reaches this browse +through "Add SSH server", next. + +**A computer added by its SSH address (audit `A27`, part b).** A headless Server Edition has no +pairing service anywhere in `src/server`, and a dev host the phone reaches only over SSH has no +nodeterm of its own to show a code, so neither can be paired. "Add SSH server" (from the computers +list, its empty state and the Pair screen) adds one by host, port (22) and user; the rules are +`ManualHost` (`android/protocol`, `ManualHostTest`) and the screen only lays them out: + +- **The key.** Set up manually using the public key below, or choose **Set up with a password** + (A120). Password setup first inspects the SSH host fingerprint without authenticating. Compare it + on the computer, confirm the match, then enter the SSH user's password. The app installs only its + retained public key and proves a fresh key-only login against that fingerprint before saving. + Passwords stay in memory for this attempt and are cleared from the screen on leaving/backgrounding; + they are never saved or used for normal connections. The manual path shows the phone's Ed25519 public key line + (`ssh-ed25519 … nodeterm-android`, the same key every pairing installs; copy or share it) to add + to `~/.ssh/authorized_keys` of that user, and a one-line command that does it: `sh -c '…'`, so a + bash, zsh or fish prompt hands it to `sh` as is (only `/bin/sh` itself is tested), which adds the + line only when it is missing, after a newline when the file's last line has none (appending onto + it would break both keys), and sets the modes sshd's `StrictModes` wants (`700` / `600`). `ManualHostTest` runs that command under `/bin/sh` and + then logs in against an SSH server that reads the very file it wrote. +- **The host key.** Nothing like the QR carries anything to check it against, so it is trust on + first use by A49's rule: Connect pins the key of the first server that **accepts** the phone's + key (a server that refuses it pins nothing), and the computer is added only then, already pinned, + so nothing ever dials it in the background before an authenticated connect. The screen shows the + pinned `SHA256:` fingerprint with the command that prints the computer's own + (`ssh-keygen -lf` over `/etc/ssh/ssh_host_*_key.pub`) to compare. A refused key says to add the + line. A login that could not finish (it timed out, or the connection dropped during it) says to + check the address and the network instead: sshj wraps both in the same `UserAuthException` as a + refusal, and only one with nothing behind it is the server saying no + (`SshHostConnection.isAuthRefusal`). An address already in the list (paired or added, same host + in any case, port and user) is refused with the name it has. +- **SSH only.** The record is the paired one's with `"manual": true` (`PairedHost.manual`): no relay + block, no host box key, `sshAvailable` true, and a `fromJson` that drops a relay a record might + carry. Its route is fixed to SSH (`HostStore.route`), Settings shows no choice for it, the late + relay adoption never runs for it. Direct Git works on admitted folders; a live selected-profile + Desktop/Server service can handle owned Board writes, and Desktop can deliver node nudges. + `LegRouting.RelayLeg.ADDED_OVER_SSH` makes a capability that still needs the relay unavailable + with "remote access isn't set up for this computer: it was added by its SSH address". This + includes canvas-registered New and Board/node actions without a supporting service. A missing + canvas session, or terminal access to a third-machine SSH project, is refused without the relay + offer. The separate phone-owned plain-terminal creation path remains available. Which refusal + is said follows the relay leg the phone has for the computer (`NeedsRelayException.refusal`): + "remote access isn't set up" for one + added by address and for one the phone never got a relay leg for (paired with remote access off), + and otherwise what is actually in the way, the way `LegRouting`'s reasons name it: remote access + turned off since, a relay not picked up yet, or the route "Only on my network (SSH)". A changed + host key stops with "forget it and add it again". Forget works as for a paired computer, and its + dialog says the phone's access is revoked by removing the line ending in `nodeterm-android` from + that `authorized_keys`. +- **Older builds.** A build that predates the flag ignores the `manual` key and reads a computer with + SSH, no relay and the same pin. The add also stores the route `SSH_ONLY` for it, which such a build + reads, so it does not dial a relay for it either (its late relay adoption could still read a + `relay.json` on that computer; this build never does). Such a build also DROPS the key: it rewrites + the whole list on its next save (pairing or forgetting any computer, a late relay adoption) without + it. So the flag is also read from the id, the one thing that survives that round trip: an added + computer's id starts with `ssh-` (`PairedHost.MANUAL_ID_PREFIX`), and a paired one's is the + desktop's `randomUUID()`, which never does. Back on this build it is an added computer again, and a + relay block or box key that build adopted for it meanwhile is dropped on read. + +## Protocol mapping + +The standing phone host still speaks the **legacy relay dialect** (`host-service.ts` +`createHostHandlers` + `framing.ts` opcodes), not the rpc.ts tunnel `docs/ios-protocol-migration.md` +describes as the future. The Android client implements what the host actually serves: + +| Phone action | Relay (host-service.ts) | Direct SSH | +|---|---|---| +| Repair eligible legacy pairing identity (`A118`) | After approved listing: `pairing.relayKeyChallengeV1 {pairingId}` → `{status, challenge?}`, then one `pairing.relayKeyProofV1 {challengeId, signatureB64}` → `{status}`; `challenge`, `associated`, `unprovable`, `gone`, `conflict`, `expired`; sign with retained SSH identity only | No proof verb; the retained SSH seed stays on the phone. No first-approval bypass or new identity | +| List projects/sessions/status | `projects.list` → the `--NT-PROJECTS-SPLIT--` blob, and beside it **`lan`** (new, `A74-refresh`): the computer's current LAN address and SSH host keys, which refresh the paired record | same blob (and never a `lan`), from `workspace.json` + `tmux ls` + `agent-status.json` in the desktop's userData or the Server Edition's data dir; the v3 index is resolved like `WorkspaceStore` (folder refs → `.nodeterm/project.json`, SSH refs → `cache`, data refs → `inline-projects/.json`). Then what a desktop that drives the computer over SSH left there (`A27`): `nodeterm-rmt` sessions, the `.nodeterm/project.json` above each, and the `~/.nodeterm/agent-status-.json` slices (stale after 120 s) | +| Open an existing terminal | `pty.attach` → `{streamId, fresh, scrollV1?}` (a session the phone starts adds `projectId`/`accountId`/`agentId`; the desktop resolves them itself — the project folder, the account, the agent's hook env and the pane's owning project — and applies them only when this attach creates the session), Snapshot frames, Output frames; a node of an SSH project is attached over that project's ControlMaster (`requireRemote`) or refused | which socket has the session (`node-terminal` first, then `nodeterm-rmt`; a reserved phone UUID uses only validated `nodeterm-phone`), then a pty exec of `tmux attach-session` on it — never `new-session`: a session of the computer's own index that is not running, or a node of an SSH project, is refused with `NeedsRelayException` and the app offers the relay (a driven project's session that is not running says it starts from its own desktop, and one no listing names is refused without the relay) | +| Type / resize | `OP.Input` / `OP.Resize` frames | channel stdin / window-change | +| Input-bar Send (`A128`) | additive **`pty.submitComposed {streamId, input:{kind:"paste"\|"control", text, enter}}`** → `{status:"delivered"\|"refused"\|"uncertain", message?}`; current local-tmux, direct native Windows PTY and negotiated session-host backends bind Send to the attached stream and captured generation; older/unverifiable hosts and SSH-project relay routes explicitly refuse | explicit actor submission to the captured Unix SSH viewer/pane; cancel host copy mode, paste with tmux-owned bracketed framing, then wait 150 ms before a separately guarded Enter; a control action is one raw byte with `enter:false` | +| Scroll (`A129`, published source; acceptance pending) | advertised `scrollV1:true` → `pty.scrollV1 {streamId, dir:"up"\|"down", lines:1..20, viewId?}` → bounded `history` page, `input`, `refused` or `uncertain`; native mouse-off reads retained rows without PTY writes; a valid view remains inert until closed. Legacy `pty.scroll` retains the positively known tmux route and safely refuses native mouse-off history | the phone writes the existing SGR wheel events to its captured attached tmux client; direct-SSH gesture/momentum provenance is unchanged | +| Detach / end | `pty.kill` / `pty.destroy` | close channel / `kill-session` | +| Wake on open (`A103`, `A104`) | existing remote-viewer nudge wakes a mounted node or resolves one saved offscreen/closed-project node without switching views; exact owner/generation and Pause guards, no fresh shell or uncertain input replay | explicit Sleeping wake offer uses the agent's measured approval policy and host capabilities; the existing shell/WakeContext checks and user tap remain | +| Wake, refresh, rename | `node.wake\|refresh\|rename` | current Desktop's selected-profile SSH service (`A108`); older hosts need an allowed relay; Server has no node nudges | +| Board | `projects.ensureBoard\|setCardColumn\|editCardLabels` | current Desktop/Server selected-profile service (`A108`), using the real save queue; older hosts need an allowed relay | +| New session on the canvas | SSH `sessions.createManagedV1` → host receipt → guarded attach; legacy relay `pty.attach` + `projects.registerNode` | A current enabled POSIX tmux host creates/registers/launches in an open local folder project. Older hosts retain the relay leg (`A26`). Durable uncertain requests never replay or switch transport. Physical managed/cellular cases remain pending. | +| Explicit plain terminal on the SSH host (`A90`) | separate from canvas registration | Sessions → New terminal → Home/project/custom absolute folder → Create; phone-owned `nodeterm-phone` session, rediscovered under Phone terminals; focused Pixel creation/history/restart/update/reconnect/exact End pass, beta 10/code 11 installed. Item 32 Partial; relay plain-shell creation/input/End pass, managed/cellular pending; A91 empty-host variant passes | +| Source control (`A107`) | unchanged `git.status\|diff\|stage\|unstage\|commit\|push\|pull\|history {cwd,…}` and typed result models; host project-root jail | the same eight verbs over typed POSIX Git argv, physically jailed to listed local/driven folders and repository root; excludes third-machine projects; bounded output, confirmed writes and exact missing-upstream-only push fallback | +| Answer a held approval (`A105`) | `approvals.answer {nodeId, pendingId, decision}` keeps allow/deny and adds allow-always plus original `suggestionIndex`; host-owned live-card/request validation; honest answered/reason outcome | legacy allow/deny file unchanged; remembered reply is marker + request-derived hook JSON, streamed through stdin with post-stream request guard and confirmed status | +| Read-ack | **`inbox.ack`** (new) | write `~/.nodeterm/acks/.seen` | +| Quick answer keys (question digits, legacy approve/deny) | **`node.sendKeys {nodeId, keys}`** (new) → `{sent}`; local tmux answers resolve the exact session to a pane ID, cancel copy mode and type there, also while mounted. Complete writes are ordered per node. SSH-project answers use the project's ControlMaster; unavailable sessions return `sent:false` (the phone opens the session). An older desktop gets attach → wait for paint → write → linger | resolve the exact session to a pane ID, cancel copy mode, then `tmux send-keys -l` there; missing exit status or any command failure opens the session without resending. SSH-project nodes go through the relay leg (`A09`) | +| Retained terminal history search (`A100`) | `pty.historySearch {streamId, query}` searches the attached generation/exact pane, returns bounded matching lines, searched-line count and truncation; older hosts refuse without replacement | resolve the selected session's exact pane and search all retained plain tmux history on the computer, with bounded private spool/result; no copy-mode/input changes | +| Cold relay attach (`A102`) | saved node/project/account/agent facts override create hints; prepare trust-aware project env/shell without spawning, recheck warm races, then respond/snapshot/attach synchronously; a warm join retains its launch facts | existing attach-only SSH behavior stays; cold agent creation is still refused rather than guessing its hook environment | +| Answer complete held questions (`A106`) | `questions.answer {nodeId, pendingId, selections:number[][]}`; original live request determines exact labels for every question and preserves tool input; old hosts open session | same full-schema builder over the selected node's original pending JSON and v2 answer file; gone/unsupported tickets never become numeric keys | +| SSH Board and node actions (`A108`) | existing `projects.ensureBoard/setCardColumn/editCardLabels` and `node.wake/refresh/rename` | same typed requests through `/ssh-actions`; fresh private advertisement, instance/nonce/host-time guards, actual WorkspaceStore save queue; Desktop nudges are delivery receipts; for these Board/node methods, Server advertises Board only; A111 separately adds conditional managed New | + +`resizedFrames` is deliberately not sent on attach, matching iOS: the phone is a size *ceiling* on +the shared pty. An `OP.Resized` still shows a "sized to another screen · fit this screen" hint. + +**Composed Send adoption (`A128`).** The additive method returns submission to the attested +pane/PTY, not command execution. Refused, uncertain and stale completions retain the draft; +uncertain input never replays or falls back to raw frames or `node.sendKeys`. Raw keystrokes, +emulator replies and wheels retain their existing semantics. The Android client and actual +`android/protocol/src/test/interop/host-fixture.ts` change together in `7e7d2c04`. +iOS **@eneskirca** needs the same explicit awaited action, exact viewer/pane receipt, paste/Enter +separation and draft-retention rules; it must treat an older or unsupported host as a refusal. + +### Why typed host verbs + +The original approval/read-ack actions already existed for a phone on SSH as owned files. A relay-only phone (every +Windows host, and any phone off the LAN) had neither: `fs.*` is jailed to project roots, correctly. +Typing `1` into the pane is **not** a substitute for answering a held hook-reply approval: while the +hook holds the request the prompt is not on screen, so the keystroke lands in the agent's composer. +`approvals.answer` routes to the same `answerPermission` the canvas Approve/Deny button uses, and +`inbox.ack` runs the same `ackDone` + unread-clear the ack-file sweep runs +(`src/main/remote/host-inbox-verbs.test.ts`). An older desktop answers "not served", which the +phone treats as "open the session" — never a guessed keystroke. The iOS app can adopt both verbs +unchanged. + +**Host-owned managed SSH New (`A111`).** A current selected-profile service advertises +`sessions.createManagedV1` only when Linux/macOS tmux is actually enabled and available. The +request carries a creation UUID, project, kind, agent/account, optional title and initial dimensions; +it carries no executable, cwd, env or hook identity. The actual WorkspaceStore/planner and PTY +manager resolve launch facts, exclusively create one `node-terminal` session, attest its kernel +process birth, publish the node, then submit the host command once. Shared project files carry +portable cwd; shell stays in machine-local execution metadata. A private 0700 directory/0600 phase +journal is synced before terminal side effects. Its actual command stays private; uncertain shared +nodes contain an inert manual recovery notice. Duplicate/restarted requests never relaunch. + +The Android host-scoped checkpoint commits before dispatch and retains the returned public receipt +until the first SSH viewer is confirmed. Adoption pins selected profile, service instance, session +creation, exact pane/PID/birth and marker, then confirms the newly allocated SSH tty attached to +that generation. It never creates an absent session or falls back after uncertainty. Explicit +"I checked the computer" permits discarding stale proof, followed by a separate list/open action. +The account picker freezes its choice; System does not silently take a later project default. +Managed canvas End still needs the relay so the node is removed from the canvas; direct SSH +Close leaves it running. Phone-owned plain terminal End keeps its separate exact-owned behavior. +Actual isolated native-PTY/tmux tests use a fixture CLI and do not claim real Claude startup. +iOS @eneskirca needs the additive action, durable request and attach-only receipt in the same update. + +## What is verified, and how + +### Upstream PR conflict integration (2026-10-08) + +The contribution merges upstream `main` at `adde5e85` with the existing Android history. +Integration regressions cover an expired structured-answer hold, the separate v2 question wait, +role-scoped phone revocation, and remote attach input arriving before asynchronous attachment +completes. The Android producer fixtures continue bundling the real desktop/Server sources; +upstream's optional SSH native dependency is external to those bundles, like node-pty. +Required offline protocol/app and affected desktop checks verify this merged source before push. +The merged hook revision and its structured-ticket floor distinguish older revision-5 scripts. +These are source checks; the merged desktop and Android app have not been exercised together +on a phone. Prepared beta23 and installed beta22 retain their earlier source bindings. + +### Pending Send outcome after remount (2026-10-08, A137) + +**Source fixed and published; beta23 prepared.** Before this change, at observed checkout `bb519eac`, installed +beta22/code23 and the archived Desktop build both used source `2ba0b142`. `TerminalDrafts.State` retains the +editor/revision/Ctrl, while `TerminalController` owns only local `submitting` and `notice`. A normal +PairHost cover disposes that controller. `TerminalActions` resolves queued input as REFUSED or +already-dispatched input as UNCERTAIN, but the old controller's current-view fence suppresses its +notice. Back creates a controller with no pending/outcome state. Once attached, it can offer Send +for the retained draft without the earlier guidance. This is a source-demonstrated visibility gap; +no physical duplicate or automatic retry is established. A136's measured no-Send pass is separate. + +The fix reserves one immutable attempt from the accepted editor/Ctrl revisions before JS +preparation through the shared button/IME path. Pending state belongs to the surviving entry and +blocks another admission. Only its exact attempt can settle; retired or different-owner handles +cannot adopt it. A stale positive becomes uncertainty. Original view/stream/actor and editor/Ctrl +revision fences still govern positive clearing, so a newer draft or rearmed Ctrl survives. +Uncertainty guidance survives edits, remount and later DELIVERED or REFUSED results until its exact +notice is dismissed; newer uncertainty gets a new notice identity. No command job, controller or +stream is restored, and nothing is persisted across process death or added to the host wire. + +**Local validation passes:** twelve entry/actor behavior methods and three explicitly lexical +Android wiring methods, focused healthy/restored **66 methods / 9 suites**, full restored +**1069 methods / 116 suites**, and forced offline app compilation. All original 1054 method names +remain. The thirteen final variants catch their intended assertions: ten behavior and three wiring +mutants, with no errors or skips. Source/Android delegation review passes; this grants no physical +WebView/Handler/Compose timing credit. The whole suite includes the existing isolated SSH/tmux/desktop +interop; new A137 cases execute entry/actor policy and lexical Android checks. + +**Publication and artifact review pass.** Signed source `b9e4cbc746e9a405844df9985f07cf47acef5607` +passes fresh forced **1069 methods / 116 suites**, zero failures/errors/skips, and offline app compilation. +[CI37740459063](https://github.com/CPlusPlus17/nodeterm/actions/runs/37740459063), attempt 1, passes all +five jobs and ten required steps at that exact source. Retained-signer `0.1.0-beta.23` / code `24` +uses the same artifact source, APK SHA-256 +`a0c336b5fb2dee849f27bc4af1cbdccaf4460828110a87322e6323cfc2a9a3a2`. +All 46 available artifact checks pass, including actual SDK36 signature/alignment, R8 and public metadata; +actual SDK37 artifact verification remains unverified. Archived Desktop build/source `2ba0b142` is unchanged. + +**Installation and physical comparison remain unverified.** A read-only pre-install snapshot refused +with no available Pixel transport; no update was dispatched. Beta22/code23/source `2ba0b142` is last +verified installed. A fresh controlled pending-Send cover/Back check still needs actual update and +fresh owned viewer/native admissions. Source tests and A136's no-Send pass grant no physical pending-Send +or duplicate-execution credit. The original 64-item ledger is unchanged. + +Private publication records: `pending-send-publication-zl2sk4sl/gates.json`, SHA-256 +`728e7658595a06cf55179112027ca75dd44164c812df42ae996d00a39679a9f0`, and independent +`a137-publication-saved-independent-ly_qo2dg/review.json`, SHA-256 +`36513b83ccfe3a989a8ec1bc62da55a2e6ab14a806b171fb830d14ab53a08bbb`. +Artifact records: `beta23-send-recipes-q06owcwo/build/artifact-review.json`, SHA-256 +`8e507c30335cd70f762c5dcdb5a9942a1815d1635490f82063a24a972cf39bf2`, and +`beta23-send-recipes-q06owcwo/artifacts/beta-metadata.json`, SHA-256 +`efdc88f2f3897584038f97d62dedfdcd104e6febe2d11b77298289fe18fd92b4`. +Independent artifact review `beta23-artifact-saved-independent-n6mszggd/review.json`, SHA-256 +`91ada275643e9f64c861335e80adc393b43ea73decf5c517e708945bb696a45a`, adds no install or phone credit. +The final mutation receipt is `pending-send-entry-candidate-6pciadbm/proof-final/mutation-receipt.json`, +SHA-256 `2b72eb43ef1b467d4367f8a7725fc22bc1efc062c0ae090c03fdfd93ea1944c3`. +iOS @eneskirca should compare local entry-owned attempt/guidance lifetimes and stale clearing. + +Private source report: `pending-send-retained-entry-source-review-5gsfw1r5/REPORT.txt`, SHA-256 +`639ac6c616077500c803dcf37fcd5d3eab9f6298aa34528f841034e5dd14c0c7`. The corrected candidate's +independent source review is `pending-send-entry-source-independent-c8sg0jmo/review.json`, SHA-256 +`3f1916ade664815f47bd1cfe54d6c81bed3092f2fb2704a3e8c7d98f17c9b4cf`; it grants no test or phone credit. + +### Retained terminal editor (2026-10-08, A136) + +**Original-beta failure reproduced.** The Pixel 7a (serial `2C091JEHN03336`) runs beta21/code22, +APK `91638d05`, Android source `ef4caec2`; the disposable host uses immutable Desktop build +`0d8594a6` while the clean source checkpoint is `675dd519`. On the independently owned `95b2116a` +terminal, composer `qa_unsent_95b2116a_remount` and checked Ctrl become empty/unchecked after one +normal `nodeterm://pair?code=bnVsbA` Intent covers it with PairHost and one Back returns. The code +encodes JSON null and is rejected locally; no Scan/Connect or terminal Send runs. The entire original +native server/socket, both Bash pane owners and both full captures remain identical. Result +`b0437b63` and independent review `19cb9739` bind the saved XML and native records. + +The first harness stopped before Intent because WebView exposes another editor. The corrected +check selects the composer outside WebView and the clickable checked Ctrl ancestor. A 15-second +display timeout stopped Back before dispatch; one ordinary wake and separately admitted Back +completed that same covered entry without repeating Intent. Same-entry continuity follows the +production push/pop code and unique title; its internal runtime key is not directly observed. +Own profile/public-key authorization/reverse are removed, the unchanged 15000-ms timeout and +install identity/full notification grant are read back, and the finite original fixture exits +successfully. Captured generations/listeners are absent and all 136 held outputs remain unchanged. +This is deliberate disposable-fixture retirement, not an ordinary app-Quit measurement. + +`TerminalDrafts` now retains the full text, selection/composition, edit revision +and Ctrl in AppGraph process memory for each real navigation entry. The complete live stack retains +covered entries. Pop/replacement and host retirement clear values and seal stale handles. Send and +mic starts read the latest accepted entry, and delivered clearing requires its exact sent revision; +newer edits, including an edit away and back, survive. Controller pending work and speech sessions +are not restored. Command text is not serialized into a Bundle or file; process-death draft recovery +remains unsupported. + +Nine store behavior methods and three explicit app wiring methods pass with the updated existing +Dictation pins. Seven draft-policy mutants (revision, exact clear, retirement sealing/value release, +stack membership, owner retirement/replacement) and one whole-stack wiring mutant are caught. +Combined healthy/restored controls pass 48/seven, full candidate **1054/114** and offline app compile +pass, with all prior 1034/110 methods retained. Receipt `098162c5` and independent `fdf4cbcf` review +qualify policy execution versus lexical wiring. A135 supplies the preceding Ctrl API dependency. + +Signed source `2ba0b142` passes fresh forced 1054/114 and offline app compilation; CI +`37732792493` passes all five jobs and ten required steps. Retained-signer beta22/code23 is installed +from that source, APK `fa894d4b`; `beta22-phone-update-bound-c2384dzh/receipts/update-result.json` +(SHA-256 `08d5b32b3bcfb245287c12fab00e2098582b971ea2591f68764b5916fac20992`) verifies unchanged first-install time, +full notification flags and all measured runtime-permission blocks. Saved artifact review is +`beta22-editor-compact-final-recipes-oov0gt3l/build/artifact-review.json`, SHA-256 +`bfff9ea6eab59735bf41e856e9b94e59c81e95d0dc0fd3c7485f3ebb5f60cdf1`; actual SDK36 verification is +available, SDK37 verification is not claimed. The installed APK/source and immutable Desktop build +remain `2ba0b142` even if subsequent documentation HEAD changes. + +**One fixed-phone comparison passes.** On the fresh `071940c8` direct-SSH terminal, +`qa_unsent_071940c8_remount` and the checked clickable Ctrl ancestor are identical in the +before/after XML through one normal `nodeterm://pair?code=bnVsbA` Intent, local PairHost rejection +and Back. No Scan/Connect, terminal Send or producer runs. The original native roles, both Bash +owners, server/socket and full captures are equal, as are both desktop client tuples. Phone tmux +client636 is replaced by834 under the same SSH parent560, target session, TTY and argv. This +reattachment is explicitly excluded from the unchanged-native-sections claim. Same retained entry +follows source push/pop and the unique title; its runtime key is not directly observed. Result +`nodeterm-new-phone-fixture-2idfmnhr/proof/root-draft-remount-fixed-result.json`, SHA-256 +`fb2a5c38e07e6daf0d952d3742c5eccd24b5b04ee9e2e9b3364dd8b3a2aa3b56`, binds case +`71d7e2cd` and both native snapshots. A preliminary all-value equality assertion stopped before +writing a result; the qualified comparison does not replay the physical case. + +The independent physical review is `beta22-remount-physical-independent-6rhn7izc/review.json`, +SHA-256 `320af8c12084905126320b042f784a5099f3eb83f745bc10684617f0850f729b`; it rehashes +86 records. Editor focus changes from true to false. This is accessibility-XML evidence, without +pixel/glyph, cursor/selection, IME, activity/process-death, pending-Send or raw Ctrl timing credit. +Existing profile/key/pin migration across the update is not established by this new-profile case. + +**Owned closure is verified.** Normal own-profile Forget returns Welcome; exactly the added public +key and reverse29453 are removed and original public authorization bytes restored. Initial null- +nonce enrollment records and the reverse-add response refusal are preserved with qualified read- +only reconciliations; neither enrollment action is replayed. The sole 900000-to-15000 timeout restore +has an uncertain post-write result; separate read-only `55b3f2d6` confirms the original 15000 with +zero repeat setting writes. Installed APK/install identity and complete runtime-permission blocks +match update-after. The original finite host exits 0 through validated intentional SIGTERM; seven +unique original outer-host generations and both listeners are retired, private auth absent, and +2516 source inputs, 136 outputs and 22 runtime pins held. An eighth recorded PID/birth entry aliases +one outer generation; no global namespace scan or ordinary app-Quit acceptance is claimed. +Canonical closure is `nodeterm-new-phone-fixture-2idfmnhr/proof/root-case-final-closure.json`, +SHA-256 `3b379602a020dada055d973629763c604dd64bd4f2abee1b480e65bc740e1dd9`. +Independent closure review is `beta22-remount-final-closure-independent-tm_gpcj5/review.json`, +SHA-256 `d036ec60cde523bf57ee1c796759bd6f159a68ee7bc7b5748833f3dd708872a5`. Welcome +XML precedes timeout restoration; the later read-only snapshot supplies package/timeout facts. + +Actual cursor/IME/activity recreation, pending-Send remount and raw-input Ctrl scheduling remain +unverified; this one no-Send retained-entry case does not cover them. The original ledger remains **10 Pass / 22 Partial / 32 Pending**. No host/relay/SSH +contract changes; iOS @eneskirca should review equivalent local retained-entry semantics. + +### Captured Ctrl consumption (2026-10-08, A135) + +`TerminalController.Bridge.onInput` previously posted a Ctrl clear guarded only by page/stream +identity. An off/on rearm before that main-thread callback could therefore be consumed by the old +input. One immutable armed/revision snapshot now covers capture, transformation and guarded +consumption. Raw input still enters the existing write queue immediately. Composed Send retains +its viewer/revision completion fences; automatic terminal reports do not consume Ctrl. + +Five `CtrlModifierTest` behavior methods and three explicitly lexical app wiring methods pass +inside the 48-method/seven-suite healthy and restored candidate controls. Removing captured-revision +matching, the current-viewer guard or no-op setter semantics produces the intended assertion +failures; removing the app stream fence is separately caught by a source pin. The complete combined +Ctrl/draft candidate passes **1054 methods / 114 suites**, with no failures, errors or skips, and +forced offline app compilation. Ten policy and two wiring mutants were caught in total. Saved +receipt `098162c5`, root reparse and independent review `fdf4cbcf` bind those results. + +This is a verified source ordering defect and tested policy fix, without a phone timing +reproduction or actual Handler execution by the JVM tests. Signed-source gates/CI and beta22 +installation now pass; physical raw-input scheduling remains pending. The original 64-row ledger +is unchanged. Pending-Send rearm/remount, native history/Live and whole-network loss remain separate +checks. No host or SSH-visible contract changes; iOS @eneskirca should review equivalent local Ctrl +lifetime without an Android-to-iOS implementation claim. + + +### Lost Send reply and newer draft (2026-10-08) + +Fresh `nodeterm-new-phone-fixture-8kjtzvr7` uses observed checkout `5a3013dd`, immutable +Desktop/build `0d8594a6` and unchanged installed Pixel 7a beta21/code22 APK/source `ef4caec2`. +One ordinary Send targets the original owned pane. The gate matches the exact production Enter +child and its stdout FIFO to the authenticated original SSH reader, then stops at its first +positive **22-byte read EXIT**. It verifies one real application ACK and witness from the original +Bash/node/TTY while the reader is stopped. No payload, tracee-memory or encrypted-packet inspection +is used. + +One normal suffix appends a newer draft without submitting it. Fresh XML observes its exact +**146 characters** and disabled clickable Send control before the reader is killed, **4.998662 +seconds before** the original nine-second hold deadline. Only the admitted original reader +receives pidfd SIGKILL from that matched syscall stop, with no detach/continuation back to +userspace; actual wait status 9 and readable pidfd confirm its death. This loses that successful +receipt after execution rather than simulating failure before dispatch. + +Post-loss XML retains the exact newer draft and disabled Send while showing +“Disconnected. Reconnecting…” and “Reattach”. The later native snapshot records a new phone +viewer/handler attached to the same original Bash, tmux server/socket and producer; the complete +sibling guard remains unchanged. Native capture still contains exactly one standalone ACK and +the journal remains initial null plus the same sole witness at a late observation **over 120 +seconds later** (conservative recorded lower bound 128.743 seconds). No suffix invocation or +automatic replay occurs. XML proves the draft/model state; this case claims no full rendered-glyph +equality, cursor preservation or observed Android internal `UNCERTAIN` status. A retained newer +draft alone cannot distinguish that status from an acknowledged completion's revision guard. + +Private proof is under the fresh fixture's `proof/` directory: +`ssh-composed-loss-result.json` SHA-256 +`293fa824e20bb6acc3f092ed62f179ff69518b27237a6a2c76a70ac69cfdf0e0`, +`lost-read-exit-ui-result.json` SHA-256 +`689e347138577581039869166a0e1c8a4b6bbc7f0f81518b4679a27cd7f0c85d` +and `lost-receipt-root-late-readback.json` SHA-256 +`8358c9d81a6874edef51287e8678ed45899dbfb47acceb4c160d25622e5504c2`. +Independent actual review `a128-lost-receipt-actual-independent-31xxhq1x/review.json` passes, +SHA-256 `41c9e7d36a6f29f7c7be3a87c03555b96f18376ee2508e69d54d7b2514a1bc5e`. + +Normal own-profile Forget, exact public authorization restoration, owned reverse removal and +verified **15000-ms** timeout restoration complete. Separate fresh device readback confirms the +unchanged APK, first-install time and complete notification grant. The original host session +exits 0 with `runner-result.json` passing: 22 runtime tools and all 136 built outputs remain held, +its listeners close and generated private authentication is removed. This is owned cleanup, +not normal App Quit or exhaustive process absence. Independent cleanup review +`a128-lost-receipt-final-cleanup-independent-5ats0kf2/review.json` passes within that scope, +SHA-256 `8b2b27e02b5e66f3fdb894994bca5267cc15db4380cca7444a85d865f4dc338f`: +seven captured outer generations are absent, 2510 immutable inputs/136 outputs and 22 runtime +pins are held, both listener tables are clear and five generated auth paths are absent. +The separate regression-test mutation window is excluded from its current-source comparison. + +The added real MINA/tmux `SshTransportTest` case loses only the second Enter exit-status +acknowledgement after actual guarded execution. It requires exact bracketed Unicode paste plus +CR, one paste/Enter attempt, an exited process and disconnected transport before asserting +`UNCERTAIN`. Healthy and restored controls each pass that one registered method with zero +skips. A mutant falsely returning `DELIVERED` after acknowledged paste fails its final status +assertion; production source is restored exactly. Private regression summary +`a128-lost-enter-ack-test-proof-thx73a_2/result-summary.json` SHA-256 +`4bc29eefecd8db2ff96bba8c0a52dc5c5c3c3b2f633e3c3669e814faddbf5d39`. +This supplies JVM transport classification evidence separately from the phone's visible state. + +This one direct-SSH successful-receipt-loss/newer-draft case passes. Whole-host/network blackhole, +native-route lost receipts, remount/Ctrl during pending Send, native TLS/history and wider lifecycle +checks remain open. No new finding, production source, wire or APK change is introduced; the +original **10 Pass / 22 Partial / 32 Pending** ledger stays unchanged. + +### Silent SSH peer recovery and single Send (2026-10-08) + +The fresh `nodeterm-new-phone-fixture-ajwhcupk` uses signed docs-only checkout `04da9b9e`, immutable +Desktop/build `0d8594a6` and unchanged installed Pixel 7a beta21/code22 APK/source `ef4caec2`. +The admitted original post-auth SSH peer 568 is stopped for **45.000 seconds** using its exact +generation. New phone viewer 886/handler 854 appears while that peer is paused. Original target +Bash 378/TTY `/dev/pts/1`, producer 605, tmux server 376/socket and sibling Bash 459/complete +capture remain unchanged. This is one silent peer on shared loopback: the listener and new +connections remain available. Whole-host sleep, network blackholing, AUTO relay fallback and +attribution to a particular timeout detector remain unverified. + +No Send occurs during the pause. Four before/during/recovered XML snapshots retain the exact +125-character draft in the same focused composer bounds. Actual PNG decoding confirms matching +text-band positions/counts before the pause, and byte-identical early/late/recovered composer RGB +pixels for `(40,2030,920,2290)`, SHA-256 +`b47c389b670dbb80561a6cea78560343d129dd97f3cb30f18563a2cf80c64a2e`. +An initial blank-rendering interpretation from inconsistent image-tool previews is discarded; +the saved pixels retain the draft glyphs and establish no new rendering defect. Cursor position +is not serialized or verified. The correction is recorded in +`a135-saved-png-pixel-correction-9cmoh7hy/review.json`, SHA-256 +`ae348ad5bdb3bad0f1e62e9bd9d398f1955a9e7f3924e881dd06cedbeb7415cf`; +the earlier visual interpretations stay preserved privately with their conclusions superseded. + +One later normal Send produces exactly one `outage` witness from the original Bash/node/TTY and +a real ACK in production capture, then clears the composer. The final journal is checked +**92.719 seconds after that invocation**, with no duplicate. A guarded SSH transport-write receipt +alone is not an application ACK; this separate witness/capture supplies that evidence. No lost +reply is injected, and relay composed-input ticket/race semantics receive no credit. +Private `proof/silent-ssh45-result.json` hashes to +`0b710828a6f14fcf1015b26b570904de31c57ce05cbdf1865eef9152be40d6d8`; +`silent-ssh-final-witness-root.json` to +`7fdc378795882a1c0ed99a3b1bda08ee5f134486d4e1a34fac345c1cf043ec96`. + +Normal QA-profile Forget, exact public authorization restoration, owned reverse removal and +15000-ms timeout restoration are recorded in `silent-ssh-root-phone-cleanup.json`, SHA-256 +`3bf9a78e1268b86d71b0d7cef8d7f5c6b95f233462b0c6401a94c36bc0c2d312`. +The runner closes with exit 0/`passed:true` after its exact stop request. Retirement receipt +`silent-ssh-root-final-retirement.json`, SHA-256 +`f3319b12002aa7456106966a1d640cb048b8136d7d2320a91f78cb743d98414f`, records all 13 captured +generations retired, listeners absent, private authentication removed and 22 runtime tools, +2510 inputs and 136 built outputs held. Its 519 excluded namespace-read permission denials make +the scan non-exhaustive; this is owned SIGTERM retirement, not normal App Quit. Independent final +review `beta21-silent-ssh45-final-cleanup-peer-_rhq3ufq/review.json` hashes to +`b4669575ab81c0edc0dbb5242232477a786011f3702907f0084dc6bf3537641f`; its earlier visual limit is +superseded by the raw-pixel correction above. Item 14's whole-host/network scope and the original +**10 Pass / 22 Partial / 32 Pending** ledger remain unchanged. Native-history, lost replies and +wider lifecycle checks remain open; the separate retained-receipt case below adds no source or wire change. + +### Held Send reply and newer draft (2026-10-08) + +The fresh `nodeterm-new-phone-fixture-hj1d1rk5` is observed with docs-only checkout `50e7a876`, +immutable Desktop/build `0d8594a6` and unchanged installed Pixel 7a beta21/code22 APK/source +`ef4caec2`. One normal Send targets the original owned tmux pane. The gate matches its exact +production Enter child and unique stdout pipe to the authenticated SSH reader, then stops that +reader immediately after a positive **22-byte read EXIT**. It retains that local result for +**9.000279816 seconds**, without inspecting payload or tracee memory. This supplies a concrete +post-execution boundary rather than inferring dispatch from disabled Send. + +During the hold, fresh XML observes the exact **146-character newer draft** in the enabled composer +with the clickable Send control disabled, **1.878384 seconds before** the fixed release deadline. +After release the same newer draft remains and Send is enabled. The original Bash/node/TTY records +one witness; native capture contains its real ACK. The journal remains unchanged **168.735781 +seconds later**, with one ACK and no suffix invocation. Original viewer, Bash, tmux server/socket, +producer and complete sibling guard stay unchanged; no automatic replay occurs. XML establishes +the exact draft/model state; the post-release PNG confirms its visible suffix, not full glyph equality. + +Private receipts are under +`/home/mgysin/.cache/nodeterm-android-work/nodeterm-new-phone-fixture-hj1d1rk5/proof/`: +`ssh-composed-read-exit-result.json`, `read-exit-ui-result.json`, +`continuity-native-observe-1791429006925922837-395824-result.json` and +`held-reply-late-readback.json`. Independent physical review +`a128-real-read-exit-actual-peer-ui3vxd0m/review.json` has SHA-256 +`61812fa43da7e8a2b1484a8cf288142254087cf8be67079f2def3b37aed37aab`. + +Normal own-profile Forget, exact public-key restoration and owned reverse removal complete. +`beta21-read-exit-fresh-foundation-ncbp_hwj/receipts/fresh-cleanup-device-readback.json` independently +confirms the original **15000-ms** timeout and unchanged APK/first-install time/notification grant; +the restore helper's uncertainty is archived without repeat write. The host session closes with +exit 0 and `runner-result.json` passes. `proof/fresh-root-retirement-readback.json` verifies the +captured original generations/listeners retired, generated private auth absent, and all **2510 +inputs / 136 outputs** held. This is owned stop/cleanup, not exhaustive `/proc` absence or normal +App Quit. Separate late/cleanup review `a128-read-exit-final-cleanup-peer-1r0sd9ws/review.json` +passes within that scope, SHA-256 +`ac748cbaf52eea7c4362290966e82309255a7e2c24bfb83f5c2772bd6dd470ae`. + +This one direct-SSH retained-successful-receipt case passes. At that checkpoint, lost reply, network +blackhole, whole-host loss, remount/Ctrl during pending Send, native TLS/history and the wider +lifecycle matrix remained unverified. The earlier deadline-missed partial result stays preserved, no new +finding or source/wire/APK change is introduced, and the original **10 Pass / 22 Partial / 32 Pending** +ledger remains unchanged. + +### Nine-second Send and newer-draft follow-up (2026-10-08) + +The fresh `nodeterm-new-phone-fixture-p4w1lpc2` uses docs-only checkout `d361ce8f`, immutable +Desktop/build `0d8594a6` and unchanged installed Pixel 7a beta21/code22 APK/source `ef4caec2`. +Only its admitted original SSH peer is paused for **9.000066 seconds** and resumed through the same +pidfd. Original viewer 633/peer 573, target Bash 377/TTY `/dev/pts/1`, producer 987, tmux server +375/socket and sibling Bash 458/whole capture remain unchanged. + +One normal Send and the suffix input both return before actual SIGCONT. The middle XML observes +the original 125-character focused draft with Send disabled and no connection/exit/uncertainty +overlay. The final newer-draft/disabled-Send XML observation crosses the resume deadline, so +`held-send-nine-ui-result.json` keeps **`passed:false`**; no strict pre-resume race pass is claimed. +Disabled Send alone does not establish SSH dispatch, and the dispatch phase before resume is +unobserved. The independently controlled lease passes without replay or extending its deadline. + +After completion, XML retains the exact 146-character draft ending `NEXT_UNSENT_553007ef`, with +Send enabled and no connection/exit/uncertainty overlay. Saved PNG/crop inspection confirms visible +suffix text; it supplies neither exact OCR nor pre-resume glyph evidence. The original Bash/node/TTY +executes exactly one witness and production capture contains its real ACK; the journal is still +at one invocation **125.310 seconds later**. The suffix is not sent automatically. + +Private `proof/held-send-nine-ui-result.json` / `held-send-nine-result.json` hash to +`c7475dba4ecadd0faa9d9c635f9b7ca5565fcac4bee702ceb5c8ea2187ef3094` / +`486585fcdeab5b4d09bfd8a692cf1862a636a2b7aabea32e02d933102e88830e`. +`held-send-final-witness.json` hashes to +`58017ce64dc4d626125138a11f0a2c52bf04098a35460a8c7c83173685574a82`; +independent partial review `beta21-held-send-nine-partial-peer-y59ymx4j/review.json` to +`bdbce739393853beaeeeac56a5271f0cada3fee8bb9eacbb04cc2bd6ec71973b`. + +Normal own-profile Forget, exact authorization restoration and reverse removal complete; the +stock APK/install/grant stay unchanged. A fresh readback reconciles timeout **15000 ms** while +preserving the original restore helper's uncertain result, with no setting replay. Phone cleanup +`proof/silent-ssh-root-phone-cleanup.json` hashes to +`2207a36a1dbf9a6dec8912b4a8218ec9877ee57f3776bcb21630d56969058ce3`. +Intentional stop closes the runner with exit 0 and the validated supervisor stop passes; retirement +`proof/silent-ssh-root-final-retirement.json`, SHA-256 +`22f9cf900ec67b495d5620cfcc65a3c542439400358cd99645720610cd90ff33`, records 14 captured generations +retired, listeners absent and 22 tools/2510 inputs/136 outputs held. Its 549 excluded namespace-read +permission denials make the scan non-exhaustive; normal App Quit is not verified. +This adds bounded later draft/one-Send evidence, not a product finding or a post-write held-response/ +lost-reply test. Native-history/relay ticket races, whole items 14/25 and wider lifecycle stay open; +the original **10 Pass / 22 Partial / 32 Pending** ledger remains unchanged. No product, wire, +build or APK change is introduced. + +### Native Desktop capture and renderer reattach (2026-10-08) + +The fresh private `native-session-host-run-smz7f03m` uses immutable source/build `0d8594a6` +with the signed `98907171` checkout differing only in four Android documentation files. The +before/after guards cover all 2510 tracked inputs, 136 built outputs and 35 public/native tools. +Tmux is genuinely unavailable inside the private home/PID/network namespace, so the normal +Terminal factory selects the bundled session-host backend. Each of the two original factories +has a correlated positive attach response and renderer fulfillment, then a unique original Bash +kernel admission before its fixed producer is started once. Both producer lineages are admitted +before the first production capture. + +All 200 unique numbered marker names appear exactly once in retained capture, with CJK, emoji +and a combining accent. Normal renderer keyboard input sends `q` once; capture contains its ACK. +A normal renderer reload preserves both node IDs and reconnects to the same daemon 1382, +target Bash 1407/producer 1464 and sibling Bash 1479/producer 1572, with their original births, +TTYs, private state/socket/token metadata and generations. Normal keyboard input then sends +`r` once and receives its ACK. The actual target raw-input journal is exactly hex `71`, `72`; +the sibling producer records no input and its complete capture stays byte-identical. This verifies +production capture retention and normal renderer reattachment/input. It does not verify full +rendered payload equality, canvas history gestures, Android `scrollV1`/Live or physical ConPTY. + +Private `proof/native-pilot-result.json` hashes to +`0b756240cde658fe57a248f7f93527b458c8a003be447f5e94ff5118b299c160`; +`kernel-before-reload.json` and `kernel-after-reload.json` share SHA-256 +`71f7310203a4349aec27d99c25c09f9795dd3ff0975df1df8ee1d5d03a03026a`. +The root execution closes with exit 0 and `passed:true`. Birth-guarded cleanup uses daemon +SIGTERM, app SIGTERM/SIGKILL and Xvfb SIGTERM; it is not normal App Quit. +`proof/root-final-retirement.json`, SHA-256 +`7803f4b22f3fd0e6aab024802bfd3bc470f23a42e61cda97f9d2efafbce42e72`, records the original +outer bwrap birth retired and no visible members of its private PID namespace; 549 namespace-read +permission denials are excluded, so the scan is not exhaustive. The original V8/V9/V13 negatives and +V14 create-only result remain separate. Installed beta21 and the 64-item ledger are unchanged. + +### Direct SSH reconnect and active background continuity (2026-10-08) + +This fresh Pixel 7a pilot uses Desktop/build `0d8594a6` and the unchanged retained-signer stock +beta21/code22 APK/source `ef4caec2`. Two original factory-created tmux panes and their kernel Bash +owners are admitted. Only the phone SSH handler 658 receives SIGTERM: it retires, and automatic +recovery creates viewer 1133/handler 1101 without reconnect input. Original producer 569, Bash 374, +tmux server 373/socket and the entire sibling guard remain unchanged. + +The staged command is byte-identical after controlled loss and both normal Home/resume cycles. +Producer write records advance **808→825** and **853→870** while the phone is on Home; the witness +journal stays at zero invocations throughout. One explicit Send then creates exactly one `outage` +witness invocation from the original Bash/node/TTY/script; native capture contains its ACK and +`continuity-after-send.xml` has an empty composer. This is a controlled SSH-handler drop, not a +45-second silent network outage or held-receipt race. Native-history/relay, port-22/TLS/FCM, +iOS/Windows, wider lifecycle and the original 64-item ledger receive no acceptance credit. + +Private `nodeterm-new-phone-fixture-5ca5k3lt/proof/` retains +`continuity-native-interrupt-handler-1791416998900172828-4064486-result.json`, SHA-256 +`bd3b9c5365713acb26b820ff5fd0f691cc9c904fa2a82a943f46f15509807f68`, and post-Send +`continuity-native-observe-1791417127474220491-4070832-result.json`, SHA-256 +`806cce70f92fb60e4a667c9c4917f3140cfdef6fee6bca5a9a6ace7da1a67c03`. +`root-phone-home-1-result.json` / `root-phone-home-2-result.json` hash to +`af493a721eaf01bbeb8cd66d4410da1b4a02d79f0a55af3a5cec845b2c0c71b9` / +`a245660ee0a6b0d2ac19409f2ea420a384c77d59cee7046777f72f52df9e7c52`. + +The custom fixture profile initially needs its private conventional `node-terminal` alias and +normal Refresh for discovery. The original producer's READY-substring check misses a wrapped +prompt; a read-only reconciliation admits that same once-started producer without replay or an +original-helper success claim. These are fixture calibrations, not new product findings. +Independent physical reviews `beta21-continuity-loss-independent-r2gidnqc/review.json`, SHA-256 +`d82d6a63a62939e76399a2a9f4145888c72bb2fa21aa59fb434a4bfda91d2654`, and +`beta21-continuity-send-independent-p1i5jgkq/review.json`, SHA-256 +`f65e105beeba054102c7d8fb5c21850522ef442180fc26d66b0e2f78925e270f`, confirm these bounded +cases. `proof/root-phone-cleanup.json`, SHA-256 +`0f2bc9f7cd20bcfd79582cc0186e714343fb4c73e76c0b7ad8f51c43846e0464`, records normal Forget +of only the QA profile/Welcome, exact original authorization bytes, only the owned reverse removed, +original 15000-ms timeout readback and unchanged APK/first-install/grant/private ADB generation. + +The outer `runner-result.json` remains **`passed:false`**, despite `exitCode:0` and held +source/tools/outputs: root directly atomically publishes the exact-nonce stop request instead of +signalling its launcher, so `intentionalStop` and `stopRequestWritten` stay false. The separate +supervisor validates that request, sends owned app/server SIGTERM and observes both exit; this +is not normal App Quit or an outer-runner success. Final +`proof/root-final-retirement-reconciliation.json`, SHA-256 +`845b31a9d8b0ab2fd1247c245b996137c7014907d9f6365b3d7921ebe057a345`, verifies the original +owned generations and listeners retired, generated authentication absent and source/tools/136 outputs +held. Its 597 unrelated namespace-scan permission denials limit global process-absence claims. + +### Login-shell probe completion deadline (2026-10-08, A134) + +The earlier passive V13 diagnostic at `70fde6b0`, using unchanged `ef4caec2` Desktop outputs, admits the +original renderer/main observers and one normal factory action. Its original create reaches +`M1-path-before` but no later stage before the unchanged 100-second diagnostic bound. It sends +zero producer input and does not establish native attachment, PTY/history or phone acceptance. +The login-shell child's profile behavior, signal handling and pending stream/close cause remain +unproven. Post-retirement empty kernel rows cannot classify its earlier lifetime. + +The local `src/core/exec-path.ts` change gives PATH and named-environment probes their own +five-second Promise deadline. Failure preserves the inherited-PATH/null fallback and settles +cache/coalesced callers once; late callbacks cannot replace the result. Cleanup uses only the +returned `ChildProcess`, signals it only while both exit fields are null, and releases its owned +stdio. It never searches for or signals descendants, reused PIDs, process groups or other servers. +Normal parsing and Windows behavior stay unchanged. The signed source fix is `3f382367`; +`0d8594a6` corrects six existing caller mocks to use Node's direct stdout callback. Every push +requires fresh exact-revision gates/CI. + +Private `a129-native-v13-actual-stage-peer-j29xcq9l/review.json`, SHA-256 +`1c41cedcdc2619944b5a447263ab9b7ad2f8547b0474bf5905ae83b74075e586`, binds the actual stage +observation and its limits. Source-bound `exec-path-deadline-proof-ubzqduoa/run-v3/receipt.json`, +SHA-256 `81b76c97a2dcc2e6c87d13ff6e4ef35d13279c6b5a82e131ef31c8b15dbaa3e2`, records +**37 passing tests in three affected files**, five existing Windows-only skips, full TypeScript, +and **seven assertion-caught mutants** with healthy/restored leaf controls (29 pass, five skips). +After correcting six caller mocks to use Node's direct stdout callback, the expanded nine-file +union passes **161 tests**, with the same five Windows skips. The single-user regression also +checks that desktop attachment preserves phone viewers; its separate `-D` mutant is caught. +Those controlled checks alone do not establish real startup. +A separate controlled TERM-ignoring child comparison leaves the original unresolved at 6253.53 ms; +the candidate settles null at 5006.04 ms with its exact child gone by 6254.08 ms and both namespaces +empty after exit (`exec-path-real-comparison-zxccybzi/actual-ado4gout/receipt.json`, SHA-256 +`0137631a112b7881506bcf40cd689fa98b96766e1fd6f5311bcf5084329f48c6`); this does not classify V13. +Independent static source review `exec-path-deadline-independent-p2a62fmt/source-review.json`, +SHA-256 `4bbf36ff60dbf9b738956309f3243f1da4aea9a4fe67f843b5634fa255ab3829`, reports no +source issue; it does not independently execute the controls or establish runtime acceptance. +The published `0d8594a6` checkpoint passes forced **1033 methods / 110 suites** without failures, +errors or skips, offline app compilation, full TypeScript and **161 tests/nine affected files** +with five known Windows-only skips. `a134-publication-41c5uiyz/gates.json`, SHA-256 +`83c5a6d7d645784b26c257e1e05a6b5f11d7e58347746ff0ab15f25743195d69`, and `ci-result.json`, +SHA-256 `f3e058762bda093339e729784c427c75760749df6f6b20b598e6f15a136fffe6`, bind the +exact source and [run `37702852854`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37702852854), attempt 1: all five jobs and ten required steps pass. + +Its fresh `94gqsiav` Desktop build uses that same source (2510 raw inputs, 136 outputs, 25.47 s). +V14's one normal factory observes the original PATH wait settle, attach request 2 receive success, +ready and renderer fulfillment for fresh persistent `pty-1`, and the visible `bash-5.3$` prompt. +The original daemon/Bash identities agree in early/final records. It sends zero PTY input and +performs no capture, producer, history or phone test; the diagnostic keeps acceptance flags false. +This does not locate V13's child cause or show whether the independent fallback fired. +`a129-native-v14-actual-independent-uu9npxeq/review.json`, SHA-256 +`171ec86b253559dacd6e1835ddb40012f16512a0ca7f3bf78db0597d65191de6`, independently reviews +that bounded result. `native-session-host-run-stjrb_65/proof/root-cleanup-review.json`, SHA-256 +`c8998f3af47b0afaff586ae62913c1a118dda1a44c94d06d2022fbf6b965860c`, records exact owned +retirement, an empty namespace and held source/tools/outputs; this is not normal App Quit. +Native producer/history/new-intent, Android and Windows acceptance remain unverified. + +This shared Desktop fix also benefits iOS companion terminal creation: flag it for @eneskirca, +with no mirror, RPC or wire change. Installed beta21/source `ef4caec2` and the original +**10 Pass / 22 Partial / 32 Pending** ledger are unchanged. + +### Beta 20 publication and bounded output checks (2026-10-07) + +Signed source `11fbff08c83bfb8403e1186e740796175e705b77` runs fresh forced **1029/108** +protocol with no failures/errors/skips, offline app compilation and full TypeScript. Actual Desktop +inputs are unchanged from the fully gated `644445aa` baseline, so local Vitest is not repeated. +Private `a131-output-publication-beta20-5ao9hwe5/publication/gates.json` hashes to +`40d3584420787739da30b7b2dd9bd48363e8465355f2017c413174d3847cf517`; its exact-head +five-job/ten-step [CI run `37659190980`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37659190980) +receipt `publication/ci-result.json` hashes to +`90407a81af43a778cb82fd74a0156269fb8bfade80f5505797bd7a58413867c4`. + +The same root's `artifacts/nodeterm-android-0.1.0-beta.20.apk` hashes to +`823b78b7efbb2ce994ecb96f35ef002729464df735204cc2a7cdc94f8df036d8`. +Its `build/artifact-review.json` hashes to +`1859f8cd360cd454fa841453a5615ca1f6ffd8b0203115d763b1bd5aac41f669`; +public metadata hashes to `ff15cb06b4421bc637d8e48debdf06e7d24dc6d0630de614ad6679ebbf0e5389`. +Actual SDK 36 verifies code 21, nondebuggable release and the retained `c610c3a0…` certificate; +SDK 37 tools remain unavailable. The actual 4.874877-second in-place update on Pixel 7a verifies +installed bytes and unchanged first-install time/notification grant and flags; the receipt +`beta20-phone-bound-zd_nnqz9/receipts/update-result.json` hashes to +`b627318b9275011a4c78f4a9c69c23728b664979b0ec724dc1099157e808ae97`. + +On fresh owned Linux/tmux fixture `nodeterm-new-phone-fixture-c04fvzwg`, ordinary Desktop +factories create distinct target/guard panes. Phone screenshots under +`beta20-phone-bound-zd_nnqz9/receipts/` show rows 1053–1094 in `flow1.png`, then 1160–1199 +in `resumedflow.png` after background/resume. Home occurs after the producer's nominal finish; +active-production overlap is unproven, so this is no background-delivery/retention pass. +`targetreopened.png` shows the scoped target's +prompt; it does not prove the numbered rows stay displayed on every reopen. `guardafterflow.png` +shows only its original prompt. Native `proof/plain-terminal-output-exercise-1791395248323-1641707-result.json` +hashes to `abdb84f3d3cb48d1a3bde499fd6903dc0ff9da1cc4278ace52180508f90bb05e`: +all 1200 numbered rows occur once, the original shell owner is restored and the guard remains +unchanged, but `flowDone`/`complete` are false. Raw `proof/root-raw-tmux-output.txt` hashes to +`29ed96409aa98a40b3a8d47064233d80e75f3538c42073eab75adf91e2c8f7ec` and also lacks +`FLOW_DONE`; [A132](android-audit-2026-09.md#a132) records the unlocated completion loss. + +A separate phone Send contains `printf 'A131_17930152_MOBILE_FINAL_TAIL\n'; exit 0`. +`mobilefinaltail.png` hashes to `5cef21d34ec337b9a3f804a0ba38552194276cfb7b9590d4670f086cf6fcc576` +and shows `[exited]`, an exit-0 overlay and no previously displayed history; `mobilefinalstate.xml` +hashes to `7e9d859cf8b2e724dea150c61ffe08fa4c1baa68b040ff5ab0b7888520539a52`. +No pre-exit capture proves emission of that final marker, and this does not establish an +Android-only cause. [A133](android-audit-2026-09.md#a133) keeps exit-display attribution open. +The owned 30-minute host retirement passes: `proof/runner-result.json` hashes to +`fbe456714d65464f8160e20463883ba0b391a4558a3634303da05322d2ff15fc`; +`proof/supervisor-result.json` hashes to `42e5d9ae083049001f16bbcf022e0c2d4449db08a042a8c7950fad5ce7d3ba16`. +Both listeners close, generated private auth is removed and source/tools/held outputs remain bound. +`beta20-phone-bound-zd_nnqz9/receipts/root-public-admission-cleanup.json` hashes to +`b43a97658f917ec7bc1ef57ed2ff795ed55e328acde0f10a0a3f6437cec8a1b4`: only the admitted +phone public-key line and reverse port 29453 are removed, and the original auth body is restored. +Normal Forget removes only the QA profile; Welcome is observed. The final phone cleanup receipt +`beta20-phone-bound-zd_nnqz9/receipts/root-final-phone-cleanup.json` hashes to +`dce22b6e06fdf9d103aacfeb9fc99029e3463395573df1b2262b86341de4281c`. +It verifies the same installed beta-20 bytes/grant/first-install time and timeout already at the +original 15000 ms. The restore helper refuses before its setting write; after ordinary wake, +Welcome and 15000 are observed again. No timeout restore write or security change is claimed. +These direct-SSH observations provide no +native/session-host history, held Live-button, physical queue-race or full checklist acceptance. +The original Pixel 10 Pro ledger remains **10 Pass / 22 Partial / 32 Pending**. + +The frozen physical checkpoint is +`beta20-physical-checkpoint-jn5pg_a6/physical-checkpoint.json`, SHA-256 +`96b4356cf2b8c8d25c75396a19b51a572f9a644a2d8cdea1b76bdbd34534be04`. +It binds 124 raw artifacts and keeps the scoped passes, partial cases, A132/A133 failures and +owned cleanup separate. These records do not establish output overlapping backgrounding or +successful final-marker emission. + +### Fresh Fedora output and native Desktop readiness (2026-10-08) + +Both follow-ups use clean `9b8bba2b`; product source and the installed beta21 artifact remain +unchanged. Paths below are private under `/home/mgysin/.cache/nodeterm-android-work/`. + +**A132 fresh discriminator.** Normal Fedora public login startup, a fresh private home, a +49×48 pane and the unchanged original 226-byte/40-ms Python command produce all 1200 ordered +rows exactly once in output-only raw recording and native capture, with one standalone +`A131_17930152_FLOW_DONE`; the tmux client's raw bytes also contain the full marker. The same +original Bash returns to the foreground. Existing Bash OSC3008 reports `exit=success`, a natural +shell report rather than a producer wait status or a final PTY-write witness. Fresh user/hostname, +private cwd and literal native send-keys delivery do not reproduce the earlier exact prompt, +NSS/hostname or Desktop paste path. The original A132 attribution stays **OPEN**; this supplies +no SSH or phone result. Exact owned server/process/socket cleanup passes. + +`a132-normal-fedora-discriminator-roamz0e_/run-hyojm26y/result.json` hashes to +`0f12736ffe6713f81380a8be1fc8c42f30e3277e03d6ed9c3855506cd6819d98`; +its `root-review.json` hashes to +`c24d515fab7756e85ef01be3c0dc179b5198d9561f04cf5bdb160181e35a8bba`. +Independent `a132-normal-fedora-actual-independent-zfratst7/review.json`, SHA-256 +`45490e72c1fb9749dfe5d1fb9ceb1b61e8d8cc70c74e37786578086ab71ef8a9`, binds 108 records. + +**Native Desktop V8 negative.** In `native-session-host-run-uno5mppu`, the ordinary factory +creates `term-muynv94j-6403f949`. Its 74 capture observations stay empty; readiness expires before +any producer input. Exact read-only renderer-navigation `-32000` recovery is preserved. Early +and final snapshots record the same protocol-2 daemon, private socket and token metadata, with +no children visible from its main task only. Other threads were not traversed. Linux's null +pane-owner result is expected; null command/empty capture do not prove attachment or shell absence. +Harness readiness remains unresolved, with no product attribution or A129/A130 acceptance. +The eight-second gate is shorter than supported cold-create budgets; it cannot classify a +startup defect. Source-budget review `a129-native-v8-actual-source-independent-utmwd1m1/review.json` +hashes to `314e5a3a065ae753414f01671ed7fbaa72db32af1b789d059a07812ab44abbd0`. +All 2510 canonical inputs, 136 built outputs and 35 tool pins agree with the saved binding. +Independent `a129-native-v8-negative-independent-6dce7opt/review.json` hashes to +`33a676c41ba01eaef75951abcdd1b58754eedd6dbdecb8aa9fe262f7a1d02830`. +Saved `native-session-host-run-uno5mppu/proof/root-cleanup-review.json`, SHA-256 +`d7c651ee487c983df17d4ae2badcc0e3774d111ce4089781a9575308e56affb8`, verifies the original +outer generation gone and zero namespace survivors before separate namespace reuse. Normal Quit +remains unverified. V9 adds the bounded all-thread observation below without replaying input. + +**Native Desktop V9 negative.** In `native-session-host-run-wtuahva_`, a 30-second shell-readiness +window still leaves the original `term-muyo5cl0-26e8f7ba` blank: 280 captures are empty and no +producer input is sent. Two complete ten-thread snapshots have no child processes at their +observation points, but belong to different daemon generations: 1064 exits empty after 30 seconds, +then capture starts 2372. They do not establish continuity or shell absence throughout the run. +No original renderer create request/settlement or attachment is proved; the saved renderer warning +also supplies no such evidence. Source, 136 built outputs and 35 tools remain unchanged. The +next discriminant is passive observation of the original spawn decision and create dispatch/settlement, +without another readiness-only run or replay. No product startup bug or A129/A130 acceptance is claimed. + +Independent `a129-native-v9-actual-source-independent-oovxt4vw/review-normalized.json`, SHA-256 +`789babc5f49a7f7f4ae43d29a3cd67184e0eb1e3f1440596c2ad3b140f26ed8c`, binds 349 records and +2510 normalized source inputs. Saved `native-session-host-run-wtuahva_/proof/root-cleanup-review.json`, +SHA-256 `cde2a35067394cdae9cb63b1e757f0d08b5cec4e289f174d36f1952ca8f29e62`, verifies the +original outer generation gone and zero namespace survivors; normal Quit is not verified. +**Browser/Share physical pilot.** The fresh owned direct-SSH fixture `vu4s5z1a` uses the same +installed beta21/code22/source `ef4caec2` under clean `9b8bba2b`. One normal Copy Links Open +initially records a focus-check uncertainty; no second Open is sent. Read-only reconciliation +records Vanadium's address field `example.com/#nt-beta21-60f53a4e`, its secure connection indicator +and rendered explanatory content. No Example Domain heading or activity-level full URI is claimed. +One Back has no verified return; ordinary app resume succeeds. Both uncertainties are retained. + +Normal Share opens the system chooser, whose XML and screenshot preview exactly +`SHARE_60f53a4e_ONLY`. One Back returns to the same owned terminal scope. Native observation +retains the original server/socket/pane generations, genuine producer and unchanged guard capture. +The selection uses screenshot/OCR glyph position y=1090 rather than the discrepant AX row center; +no failed AX tap occurs in this run. No recipient/delivery, ended-page retention across browser +backgrounding, A129/A130 or wider matrix acceptance is claimed. +Normal Forget reaches Welcome; only the original added public-key line and owned reverse are +removed. Exact intentional fixture stop retires all admitted process generations and its namespace, +closes both listeners and removes five private-auth paths; 22 tools/136 outputs remain held. This is +not normal App Quit. Timeout restore writes the original 15000 ms once, then loses post-write focus; +that uncertain receipt is retained. A later read-only check confirms 15000, reverse absence and +unchanged installed APK/first-install time/notification grant, with no second setting write. + +Phone paths below are under `beta21-browser-share-phone-bound-ipaufgcy/receipts/`: +`browser-original-open-reconciled.json` hashes to +`eab11ce62cedddb344d2f289f0156ae5b67433183c1a7389dff999da53e32bb1`; +`external-observe-7ff8c2d7cec04279bcf1cc2d352d8231.json` hashes to +`5e7c711675ae8fd9068ee239656d547b930428b1eea2c27b669504ec903eae17`; +`external-chooser-back-result.json` hashes to +`53c24a531a9e3a59db1fab18fa1166c68f86e090395aad98738c605ca67d94e0`. +Native `nodeterm-new-phone-fixture-vu4s5z1a/proof/browser-share-native-observe-1791412545417254277-3571190-result.json` +hashes to `36b6da39f892d11fa5d88e8cf6e972fee0fd47fb0ba06cb4d0d6e32d4c381084`. +Cleanup `nodeterm-new-phone-fixture-vu4s5z1a/proof/browser-share-root-cleanup-review.json` hashes to +`51b138d878baa3c85b97767c4bca74b30e54a6b7b13d07f430ccfb11293af824`; +phone `screen-timeout-original-restored-readonly.json` hashes to +`6aa57e3ffbd9080c9d52123b869356ecd495b6301dd71e5c8e401a5696137e86`. +Independent `beta21-browser-share-physical-independent-nntykgrg/review.json`, SHA-256 +`f566c4d258a8aec47b619a4af20c9cac351b638cac9f933487a424afce98e58d`, confirms these bounded observations and cleanup. +The original 64-row ledger remains unchanged at 10/22/32. + +### Closed SSH/tmux exit display source checkpoint (2026-10-07, A133) + +The frozen native exit discriminator `a133-tmux-raw-exit-v3-rpn5n03z` records immediate and +150-ms-delayed final writes: each confirms 17 bytes containing `A133_FINAL_TAIL`. Its actual +outer tmux suffix clears the alternate screen with `CSI 2 J` before `CSI ?1049l`, then writes +`[exited]`; capturing only at alternate-screen departure is too late. Results `7574288b…` and +`95700b01…` bind both final traces. Replay checks settled parser/Copy state, not whether the tail +was briefly painted during the recorded delay. The earlier beta-20 phone marker remains unconfirmed. + +This change passively captures the current visible alternate pane before erasure, including a +blank pane which replaces an older capture. Only the current known SSH/tmux viewer's EOF opts in; +an xterm parser-drain barrier and viewer/revision checks precede the closed display. Native/relay +paths stay unopted. Copy and touch links use the captured physical rows/wraps/URLs; there is no Live +button or PTY input from that layer. Settled rows and Copy survive font/layout changes; new attach, +paint/reset, page retirement or suspension clears them. Backgrounding still uses the existing +reattach flow. This preserves the last rendered pane on one page, not backend history or bytes +tmux never painted. Control sequences are neither swallowed nor stripped, and no tmux capability, +live TUI behavior or transport contract changes. The equivalent iOS EOF/display implication is +for @eneskirca to review. + +Actual shipped-xterm component controls pass **23 cases**, then **26** with layout-touch, wrapped +plain-URL touch and outer-TUI/query controls. Private +`a133-augmented-control-root-zruk3bem/result.json` hashes to +`90d9df1e2820eea048d60aebcf0cafa326fcb3e7f974f60dec0734541fd4aef2`. +Eleven semantic mutants are assertion-caught with healthy/restored 23-case controls in +`a133-component-mutations-lg2vrii9/receipt.json`, SHA-256 +`a8d17ca753eb00d30d90109745ab8435738adfdf6b96236b04003f0bedb6cba6`. +An additional stale-geometry mutant fails the intended assertion against all 26 cases; +`a133-geometry-mutant-root-0fapgav5/execution.json` binds its result `fdcf14f3…` and recipe +`83172a2b…`. Grouped EOF/origin policies do not establish individual redundant-clause coverage. +These are real xterm5.5 parser tests with jsdom layout/canvas stubs, not WebView/phone/SSH-runtime +acceptance. The permanent behavioral JUnit method and three explicitly labeled app source-pin +methods pass in a fresh forced offline focused run (`a133-permanent-focused-t_nycdfq`), with all +26 component cases required and no failures/errors/skips. Those focused checks predate the final +27-case layout follow-up and publication below; they supply no physical acceptance. The original +**10/22/32** ledger is unchanged. + +The three app source pins are separately mutation-checked: removing SSH-only admission, the owned +EOF display call or background capture retirement fails only its intended assertion, while healthy +and restored controls pass. `a133-wiring-pin-mutations-zxu7m35n/run-h0czovso/receipt.json`, SHA-256 +`8ae3c307ba0f98a0f148ea9f5f50e2a58a14cb8f5222f0f317ca6fd6378c36d0`, retains five runs of the +same three compiled test/helper classes. These lexical source assertions are separate from the +twelve renderer behavior mutants and provide no device acceptance. + +**Integration regression and repair.** The first full run at signed `94c8d2c4` executed 1033 +methods in 110 suites and failed the existing delayed-submit swipe assertion (no errors/skips). +Its retained `a133-publication-beta21-gates-v3-t_51tdks/protocol.log` records the failure; that +revision was not pushed or installed. Geometry invalidation incorrectly advanced the submission +epoch. Advancing that epoch only when the viewer/display clears preserves a same-view delayed +Enter while keeping begin/retire/paint/reset/pagehide/suspend barriers and pending EOF revision checks. + +A fresh forced focused run (`a133-layout-focused-ndsg_4be`, result `809132cf…`) passes all 16 +methods across the closed-view, wiring and existing swipe suites. The behavioral method now +requires 27 exact cases, including combined refit/font/resize during submission. Reinstating +the old epoch placement fails only that new assertion; 27-case healthy/restored controls pass +in `a133-layout-mutant-jberi26g/receipt.json`, SHA-256 +`9f0a3879bfa081cc8be4199c63c88dce2119a5475c6fd3cb5915ad68d987373c`. +This additional mutation is separate from the earlier twelve component and three source-pin +mutants. Final signed/pushed `ef4caec2` passes forced **1033/110**, offline app compilation and full +TypeScript; exact-head CI `37678829862`, attempt 1, passes five jobs and ten required steps. +Private `a133-publication-beta21-layout-gates-kvsnfmww/gates.json` hashes to +`0a3536e129651daefbd17e26d38caf06bd39fb909002914f28f7feb327d4a733`; +its `ci-result.json` hashes to `bb6a2b17ad0ce8da0205e80fe6abd8eb7d5786df0ea8ebeafab05a513908e34a`. +The signed beta21 artifact review hashes to `19228d8a3605ea8461b4b54acab205b5a51eca49b2d8148737e88eb2ef367676` +and passes all 46 checks using actual SDK 36 tools, with no actual SDK 37 verification. +The installed beta 21/code 22 APK is bound to this artifact source; later docs-only heads are separate. +Its 5.083-second update keeps first-install time and exact notification grant/flags. The later +bounded physical closed-screen observations below are separate from installation/parser evidence. + +**Bounded beta21 active-output follow-up.** On the fresh owned direct-SSH/tmux target, actual +40-ms output completes all 1200 ordered rows exactly once in raw capture, and a successful +24-byte `FLOW_DONE` write reaches the final native capture and phone screen. Actual progress +publication occurs between the root's completed Home input and resume receipt; Quickstep then +nodeterm focus is recorded. The crossing relies on filesystem/receipt order because the generic +phone input receipt does not store key arguments or a monotonic timestamp. The measured producer +duration is 48.23 seconds. The final screen contains rows 1160–1199, Unicode, the marker and one +wrapped ordinary prompt; a later swipe shows older native tmux history. The sibling guard's +process and complete capture are unchanged. Private +`beta21-flow-background-independent-_hyv21hq/review.json`, SHA-256 +`eb15e8892941bbf754d8d4ec2f37fbf5f15fbe83ca45a0246fbf5d79fa4ee00b`, binds 36 records. +This neither identifies the original A132 negative's cause nor proves A129/A130 native-history +behavior, the A133 closed-screen check, wider lifecycle latency or overall fixture cleanup. + +**Bounded beta21 closed-screen check.** The original owned target emits eight Unicode rows, +wrapped text, an OSC8 anchor, a 128-character plain URL and a successful 25-byte LF final write. +The pane recorder contains 1396 bytes (LF becomes CRLF), the target ends naturally with exit 0, +and its output-only logger receives pipe EOF. The phone's same-page closed screen retains the +rows, wraps, final tail and logout with Copy enabled and sending disabled. At that first pilot, normal Copy-sheet +filtering and one selected URL are observed without proof of clipboard contents or final-marker +copying. Exact original OSC8 and full plain URLs are offered before font changes; A-minus redraw +retains the pane/tail and A-plus restoration is root-observed. Post-font link hits were not yet +verified at that checkpoint. Private `beta21-closed-physical-independent-xs0wdxrr/review.json`, +SHA-256 `7145b2cac1c7fbfed39503de5ef00acec8c1bfbce13650bfe6b3b1e5dee364ab`, binds 31 records. +Opening the sibling guard retires the same-page old closed text. This supplies no unrelated live-TUI, +native-history/held-Live, persistent-ended-history or full-checklist acceptance. + +**Later beta21 clipboard and post-font link pilot.** A fresh owned direct-SSH/tmux fixture +`nodeterm-new-phone-fixture-nvfslwqv` uses the unchanged `ef4caec2` build/APK under docs-only +`9e740cec`. Its original target completes a witnessed 25-byte final write; the output-only +logger receives EOF with 1402 raw bytes, SHA-256 +`dd9c14d73dbc156f02a5abf340c5dfaea7324b46f28fce16034b64970a216e16`. +The original sibling guard's process and complete capture are unchanged before a later deliberate +view of that guard. Normal Copy selects only the visually checked final-marker row, then only +`03 Ω🧭`; normal clipboard paste into the independently verified empty ended composer yields +exact `A133_c9981a9b_FINAL_TAIL` and `A133_c9981a9b_03 Ω🧭`, with Send disabled. +The exact 74-character OSC8 and 128-character plain URLs are offered after both A-minus and +A-plus restoration through stationary native touch hits at the current visible glyph positions. +Browser navigation and Share were not tested. The first AX-derived row coordinate selected a URL +rather than the marker. Screenshot-calibrated stationary touches select the actual visible row; +later paste admissions independently verify the empty exit-0 composer and disabled Send. That +fixture calibration is not a new Copy-index product finding; the failed initial overlapping +composer-clear capture receives no credit. Raw XML/PNG receipts are under +`a133-copy-link-followup-v3-s3jznolt/phone/receipts/` (`selected-final-tail-visual.png`, +`pasted-final-tail-visual.xml`, `selected-unicode.png`, `pasted-unicode.xml` and all four +`osc8/plain-after-smaller/restored-font.xml` files). Native ownership/write/EOF evidence is +`nodeterm-new-phone-fixture-nvfslwqv/proof/native-fixture-stop-1791407461302681663-2796520-result.json`, +SHA-256 `e276c131230b2e9130ab93cc442c1da205741b1b4978461c536792c8f09d48b5`. +The calibrated final-marker review is `a133-clipboard-calibrated-independent-4_cp_agf/review.json`, +SHA-256 `9d48a7a0ba23cc236bf693ebef89ab7ba342597bfb1ae37084b3c056a6665f25`. The Unicode/post-font +review rehashes that proof and 81 source/evidence records: +`a133-unicode-postfont-independent-9l6o_i4c/review.json`, SHA-256 +`4ba6927d86a79ae4c07b85c39ed86ce12f1294de2bf68e4160fbacca198bf2f7`. A later normal opening +of the sibling guard shows only its own prompt/title, with no old closed rows or exit overlay. +These bounded same-page checks do not establish persistent ended history, unrelated live TUI, +A129/A130 native history, the original A132 attribution or the full 64-item ledger. + +**Later pilot owned cleanup.** Exact pidfd/nonce SIGTERM completes the current host runner with +exit 0; its receipt is `89d9022eca1f787c3e27d92ce662cb97cf1ce10015d815cbb99f50baf230157e`. +`nodeterm-new-phone-fixture-nvfslwqv/proof/root-cleanup-verification.json`, SHA-256 +`7d1e316dd88764391707a7a5a31e91bb6f67ea2aa777ecdd4f98d037251dbdb8`, confirms seven admitted +processes and their namespace are gone, both listeners close, private authentication is absent, +and runtime pins/held outputs remain unchanged. This is owned SIGTERM cleanup, not normal Quit. +The exact admitted public-key line and reverse are removed, original authorized bytes restored, +and normal Forget returns only the temporary profile to Welcome. The first timeout restore writes +900000→15000 but fails its post-write window check; its uncertain result is preserved. A retry +refuses before another write, and a separate guarded read confirms 15000 with the same APK, +first-install time and notification grant/flags: +`a133-copy-link-followup-v3-s3jznolt/phone/receipts/screen-timeout-restore-reconciled.json`, SHA-256 +`088a91d9f917a949a7e82449c752a0e18c52b2102060244c44ed0ff3d5211cee`. +Independent saved-evidence cleanup review is +`beta21-copy-cleanup-independent-03y913yc/review.json`, SHA-256 +`8e7433a47948f6d623c17da92b4c7b066174236c47c5fe3863b5c7ba5ce67932`. +It rehashes 184 evidence files and all 136 immutable build outputs, checks 2510 source entries +under the docs-only current/build binding, and confirms 2506 non-doc canonical entries and modes +agree. Recorded cleanup, the new guard view and actual 15000-ms reconciliation pass; the reviewer +performs no runtime or device probes. + +**Earlier pilot owned cleanup.** Root's exact pidfd/nonce stop completes the host runner with exit 0; +`runner-result.json` hashes to `18d31a6eaa3ca12c5a89ea49085e17e2ec3dd1b05a45c8e19f9c61f43b24f01a`. +The supervisor's admitted app/server SIGTERM cleanup passes; ordinary native Quit is unverified. +The exact processes retire, SSH/CDP listeners close, generated private authentication is removed, +and 22 runtime pins/136 held outputs remain unchanged. The one admitted phone public line and +owned reverse are removed, the temporary profile is forgotten, and the app returns to Welcome. +The same fresh timeout lease restores 15000 ms. The installed beta21 hash, first-install time +and notification grant/flags remain unchanged. Cleanup is separate from target/logger natural exit. + +### Viewer output lifetime source checks (2026-10-07, A131) + +Final forced focused control/restored runs pass **71 methods / 8 suites**, zero failures/errors/skips. +Eleven new methods exercise the actual Kotlin queue/page/slot with an explicit scheduler and paused +producer; three new source pins check app wiring. The existing keyboard-chip source pin follows +its now page-global focus call. The unchanged pure queue/page/test catches **ten behavioral mutants** +in a frozen first window (32/3 control/restored); the final controller catches **four app source-pin +mutants** in a separate 71/8 window. The earlier three wiring variants are historical, not extra +unique mutations. These checks do not run Android Handler/WebView or reproduce a physical race. + +Private first-window receipt `a131-output-proof-6yng04t0/receipt.json` hashes to +`67424776a015bfa3bc7b3bb45bb773fc24408a8b73b816bee1c4a4a5f60c3cae`; +final receipt `a131-output-final-proof-p06l8grq/receipt.json` hashes to +`9271e1a22ebfd50c237486bdded708becc629df12fc5cd19e03f58dcbdb2e9a9`. +The six-file final source inventory hashes to +`a8997ab31466b732a855137bd00a631db4a61ac241e18a1e04395aa0ab89a313`. +Root independently rehashed both windows' logs/compiled descriptors/XML; peer review checks the +actual intended assertion failures and reconstructed mutations. Signed `11fbff08` now passes full +publication and same-signer beta-20 installation; the bounded physical checks above are partial, +with A132/A133 open. JavaScript already dispatched to a ready WebView is outside this fix. +Items 25/26 and the original **10/22/32** ledger remain scoped. + +### Beta 19 publication, provider and emulator checkpoint (2026-10-07) + +Signed, pushed `5bda2c321118712bb7684eda2b829eaa427f58d7` includes A130's held Live-touch fix. +Fresh forced offline protocol checks execute **1015 methods / 106 suites** with zero failures, +errors or skips; offline app compilation and full TypeScript pass. Exact-head +[Android run `37608652625`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37608652625) +passes all five jobs and ten required steps. The private `a130-publication-final-kyernmsh/` +receipts are `gates.json`, SHA-256 +`a2c06567f48f3583e3aa34dcc4881031626eb94a98afe23c6d0c4168cac892d7`, and `ci-result.json`, SHA-256 +`77c8a5c5dc92a4b41f3b84f3819d0756542f9aeb1d26f6e66e9b5f57b9d89135`. +Retained-signer beta 19/code 20 hashes to +`e7271ebe1c1d65d8f6a09f7c59ab4b0042081c152c421568caabdcf6965bad42`; its actual SDK-36 artifact +review is `beta19-final-recipes-r5x3c9z6/build/artifact-review.json`, SHA-256 +`9157b0f7b1369c1a1e899c67cb4a82dae973e5eac286f2cf28483f972c202186`. +SDK 37 is absent; no SDK-37 artifact verification or A129/A130 actual app/WebView acceptance +is claimed by this checkpoint. One 6.18-second same-signer `adb install -r` updates the intended +Pixel 7a from beta 17/code 18 to beta 19/code 20; the installed public APK hashes exactly to the +reviewed artifact. First-install time and `POST_NOTIFICATIONS`'s `granted=true` plus full flags +are identical before/after. The installer launches no app and makes no UI input; the later +connection check below is separate. Saved-profile/pin reuse is verified in the later follow-up; notification behavior remains unverified. +Update request `beta19-pixel7a-ui-v2-pzsxl_p5/receipts/update-request.json` hashes to +`6ffca2c1f454c82cd7605feb2d586af2148340272feef15414b562ed6d22d11e`; actual +`update-result.json` hashes to `a5478736d7cdedb48e12febdd5493e9cd522c5b1107c01ab66bad92defcad29e`. + +**Pixel 7a approval and pin follow-up (2026-10-07, Desktop `e06b5547` / installed beta 19).** +The phone's normal Always allow dialog selects the original `localSettings` suggestion for +`term-muy5k94n-e14b1265`. Real Claude 2.1.292 in the admitted sibling uses the genuine registered +node routing and unchanged production hook: **nine hooks**, one **36.814-second** hold consumed, +two actual Bash calls with no second hold, and the exact CLI-saved rule. The 45.982-second case +exits successfully. Private `nodeterm-new-phone-fixture-ik57x6_y/home/qa-project/real-cli-a105-f862d70c/proof/result.json` +hashes to `615b64dfac6818f578f138dd479eb296b434c81370a852f3ac5ae99400772a38`; independent +`a105-physical-independent-review-v2-_idtfg29/review.json` hashes to +`99190d6e439fe957dceae70a22c2eadd7c17e54b9a8e992d71b2c08a44c12b79`. This verifies the Android +choice plus real application; it does not prove rule persistence after CLI restart or a +production-pane-child launch. Earlier tool-less print calibration supplied no +held question or Android acceptance. The original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. + +**A106 original question application.** Beta 19's real Send answers submits Cobalt for the +single question and nonadjacent Lint/Docs for the multi question in one original AskUserQuestion. +The unchanged production hook preserves the questions and returns `updatedInput`; actual +PostToolUse, Stop and final PTY output match. Private +`nodeterm-new-phone-fixture-0_hyrpew/home/qa-project/real-cli-a106-c6340613/proof-interactive-v2/result.json` +hashes to `f2af01489636c723713e269917acd77ee6954690b8ee84998d2c628762af1dff`. +The owned interactive provider is explicitly stopped/reaped after completion, not a natural exit. +Independent `a106-physical-independent-review-zudb36tm/review.json`, SHA-256 +`c29c103b952fafa285a92a8519682c53f9ab213a9fe40560fa1990c2d2640d01`, checks 79 source/evidence +bindings and the byte-exact original ticket, hook reply and final summary. This two-question case +uses no question scrolling; the wider question/layout matrix remains open. + +After force-stop/reopen, the saved profile connects and lists both original projects without +repinning (`beta19-live-inbox-ui-wake-0h__gp4s/receipts/after-app-restart.xml` and +`saved-profile-reconnected.xml`). A fresh second host on the same owned reverse/high port presents +another key; the phone shows both original and new fingerprints and refuses before authentication, +although its public key is authorized. `nodeterm-new-phone-fixture-0_hyrpew/proof/root-changed-key-result.json` +hashes to `8155b352d125c8df32e416f80fe7d3812743370e8df9cc2f4fc5aad4050b25ec`; the paired +`changed-key-refused.xml` and bounded sshd log show zero accepted phone authentication. This is +manual direct-SSH pin enforcement, not QR/relay fallback or DHCP refresh. Both owned fixtures +retire cleanly; the final stop proves the nonce, seven owned process births/namespace gone, closed +listeners, removed private auth, and unchanged 22 runtime pins/136 outputs. Private +`nodeterm-new-phone-fixture-0_hyrpew/proof/root-final-cleanup-review.json` hashes to +`a98cdddaf3c432021929cc9f1b34d53cc60e23d322137a49988f07d03d1f7732`. Normal Forget removes +only the QA profile; the exact phone public line and owned reverse are removed while preserving +unrelated authorization/routes, and the original 15000-ms timeout is restored. Private +`beta19-live-inbox-ui-wake-0h__gp4s/receipts/root-final-phone-cleanup.json` hashes to +`3ec41e0ef942075bc0b5c08eb4a678c6dc9628ca0f382c3a4444539ac49c4d13`. Publication requires fresh +exact-revision offline gates and all five jobs/ten critical CI steps, retained in private publication +receipts; the existing beta 19 APK stays installed. + +**Earlier fresh direct-SSH admission.** Beta 19's normal Add SSH server screen displays the same public +key as the pre-beta-17 admission: SHA-256 +`24fa408ccdbd50a77ae9328cdf2f8a247c4661370b50517c1993ed756c3dbcad` of the displayed line. +A new temporary profile uses `qa@127.0.0.1:29453` and the explicit +`/home/mgysin/.config/nodeterm-new-phone-fixture` folder. Its successful Add SSH screen shows +the first host-key pin `SHA256:cKHWVYY2dvG+/iYairy/ZWCLnjqu8PsHOj81i2dWT44`, matching the fresh +owned Linux fixture's ready receipt. This is physical Pixel 7a SSH authentication through an +owned ADB reverse tunnel, not LAN, cellular, QR, relay or changed-host-key enforcement. No old +saved profile/pin is reused; notification behavior and A129 native/session-host history or A130 +held Live-touch behavior remain unverified. + +The fixture is prepared through the ordinary Desktop renderer factory and public preload API: +one original local project and two normal Terminal UI creations retain their original pane +owners and full capture bytes across a normal reload. The shell's capture contains all 1800 +sequential markers; these are setup observations. The separate physical checks and exact owned +cleanup below have their own raw evidence. Private setup proofs are: + +- `beta19-pixel7a-ui-v2-pzsxl_p5/receipts/beta19-connected.xml`, SHA-256 + `5486fbca471c85178ff60cecd8c542689e495ea211d21cc7369f3db7083f031d`. +- `nodeterm-new-phone-fixture-0z36dob3/proof/root-phone-key-admission.json`, SHA-256 + `eb2594e7c2b1beef65511a1dd611b04932d91af22cacaf8e069712facad72fb4`; the prior public identity is + bound by `nodeterm-new-phone-fixture-q5v1nuem/proof/physical-pixel7a-beta17/01-public-key-admission.json`, + SHA-256 `457b3812c895fc12aa038ba4135f59bfcc5734ee49d1d5e519e44f5fabef9c9f`. +- `nodeterm-new-phone-fixture-0z36dob3/proof/cdp-1791372226890-323869-result.json`, SHA-256 + `462d35c846911a0c5267179264d56d9d35a5fe2205c7cdb7ee81119aad55e05a`, and its post-reload + `cdp-1791372268631-334926-result.json`, SHA-256 + `d855b36fa4cefeb7247f8fdd1fc1798285ba6ae9888f0387eeadd838b440358b`. +- Independent read-only setup/source review + `beta19-cdp-setup-independent-bvt63heg/independent-review.json`, SHA-256 + `c243e7170fb5872ccbb181701e5e4af3e3c4813038aa951b5c2e0566f4dc7ab2`. + +**Bounded beta-19 Pixel 7a checks.** The original `%1` shell remains PID 460/birth `14650700`, +with server PID 379/birth `14650581`, the same private socket inode 288 and unchanged sibling +`%0` guard. At 50×43, emacs and vi copy-mode Send each executes its own witness exactly once +(counter/output line 1), cancels copy mode and retains all 1800 earlier markers. The emacs +acknowledgement clears the draft. One observed launcher/background→app resume preserves the +draft; this does not establish process-death, long-background or notification behavior. + +Find returns one matching line in 1806 retained lines for the first +`BETA19_QA_e7685ad9 0000` marker while preserving the unsent draft. The separate +`beta19-pixel7a-ui-v2-pzsxl_p5/receipts/beta19-find-result.xml` receipt hashes to +`a9e9ade92de37cf6543163019a717fa930de69249e8190f4efc03c3d035301a3`. + +Terminating only viewer PID 1643/birth `14692640` shows the neutral “terminal connection closed +(exit 1)” notice, disables Send at its actual clickable ancestor and retains the draft. Manual +Reattach creates viewer PID 2679/birth `14717301`, preserving the producer and the complete +byte-identical 50×43 history; sending the retained draft adds exactly one execution (emacs counter 2). +Terminating only that connection's SSH handler PID 568/birth `14660326` then automatically +reconnects through handler 3015/birth `14723893` and viewer 3079/birth `14723975`. The vi draft, +producer, guard and complete same-grid history survive; the counter stays 1 until explicit Send, +then becomes 2 and the draft clears. The listener is not signalled. Temporary viewerless geometry +is 88×24, so no same-grid history equality is claimed during that interval. The two screenshots +show ordinary tmux history moving to earlier rows; they do not exercise A129's inert native history, +A130's held Live button, FPS or momentum. + +Normal Forget removes only the temporary QA profile. Cleanup removes one exact phone public-key +line from each of the two owned fixture homes while preserving all other authorization bytes; +the owned `tcp:29453` reverse route is removed and the original screen timeout `600000` is restored. +The current `0z36dob3` fixture stops through its nonce-validated supervisor: app/server exit, all six +owned generations retire, both listeners close and generated private authentication is removed. +All 21 runtime pins and 136 built outputs are independently rehashed unchanged. This is controlled +owned cleanup, not ordinary app Quit or a new managed End test. + +The independent measured record `beta19-bounded-physical-review-sqflgg52/measured-cases.json` +binds 74 raw files, SHA-256 `20d4e29ae1e07609c5b3df96db3e2a0a1100614cb9cf157ae7b9b1e454cbc9e1`. +It includes the separate UI, native counter/history, signal and cleanup records. Actual current-host +`nodeterm-new-phone-fixture-0z36dob3/proof/runner-result.json` hashes to +`bca0f7a8e5697dc77f97fe30d3fba76b91b2e93d8b9cbc7a28ffe99d0c8a8d19`; fresh +`nodeterm-new-phone-fixture-0z36dob3/proof/agent-stop-verification.json` hashes to +`fe046dda3d77702886305694123494bbf2fbb816e4a17932c0f9da9cb4cc2db4` and binds this host's stop request, +nonce, process generations and unchanged outputs. The deterministic successful runner result also +matches the prior fixture's content hash; the current path/generation is separately verified. +At that earlier checkpoint, these Linux/direct-SSH observations left live A105/A106 and changed-key +acceptance open alongside A129/A130 native app behavior, held Send races and the full device matrix. + +Normal existing Claude authorization succeeds for **one bounded tool-free inference** in +**3.865 seconds**, costing **$0.002988**. It copies no credentials, invokes no tool and verifies +neither held remembered-rule application (`A105`) nor full-question application (`A106`). +Their historical HTTP-401 attempts remain separate; this request establishes usable inference +for the measured normal route only. Receipt +`claude-provider-probe-proposal-6tr5n7o6/normal-auth-plan-v2/execution-record.json`, SHA-256 +`ec5c129e4d69446fb828099a8b4bf82160ba4105e7168d31e98c4ada6363fe5d`; +its execution manifest hashes to `c41b2bbe5b75fbf8d23c592a043abbb1280058ecbb8cb19b30e13953d12bfb86`. +The same reviewed sibling namespace/auth route separately passes a tool-free inference in +**3.392 seconds / $0.00285**; `claude-sibling-auth-probe-v2-hxhkl2h_/proof/result.json` hashes to +`5574a5f9e17b5b55602c18a583a483afd43ffeb8495052737ea7fb3c0006234f`. Safe-mode disables hooks, +so that result supplies no application acceptance. + +**A105 Desktop application (2026-10-07, source `41406fef`).** Real Claude 2.1.292 runs in the +sibling namespace with genuine captured production-node routing and the unchanged managed hook. +The shipped Desktop preload consumer selects an original `localSettings` rule: nine actual hook +events, one consumed/removed PermissionRequest hold, two Bash executions without a second hold, +and the exact allow rule saved by the CLI. The **8.648-second / $0.0166058** case exits successfully +and reaps its CLI. Private `nodeterm-new-phone-fixture-h7nho19u/home/qa-project/real-cli-a105-44b6a670/proof/result.json` +hashes to `741b0f5a73522ec76bf29ebeab7a0cf43b9c2b19a47da0f022ecc0369356c228`; independent +`nodeterm-new-phone-fixture-h7nho19u/proof/root-a105-application-review.json` hashes to +`ee9e1fd9dc3fc191b1d6ede108347252d29265dc4222143bb7d4a60f97c6d434`. At that earlier checkpoint, no phone input or production-pane-child +launch was claimed; Android remembered-rule acceptance, A106, cross-process persistence and the +wider matrix remained pending; the original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. + +The newly created API-30 emulator initially boots with measured root capability in **34.28 seconds**. +Its separate provisioning fails: the first public CA write meets a read-only system, then the +single authorized guest reboot never reaches a changed boot ID/live ADB within its 300-second +observation. One subsequent serial-scoped private reconnect also stays offline for 60 seconds. +The owned console responds with “virtual device is running”; its one official framebuffer +request times out after 10 seconds and produces no PNG. One bounded read-only GDB stack capture +then detaches explicitly, with the same QEMU non-stopped and `TracerPid=0`; the waiting renderer +threads and stripped frames do not establish a conclusive cause. No app launch, installed APK, +CA trust, TLS/relay or A129 app acceptance is established. All work targets this disposable +emulator's private ADB port 5039, separate from the physical phone. + +Private raw proofs (under `/home/mgysin/.cache/nodeterm-android-work/`) are: + +- Initial `a129-disposable-emulator-h4w8ds5l/proof/ready.json`, SHA-256 + `887fec47bfede3bf28c578c839de9bf78d15b9c886630a009e12f3facfee2b22`. +- First CA-write refusal `a129-disposable-provision-fjf7ufxt/proof/provision-result.json`, SHA-256 + `325009b480e3c259997a85e50004820e05c7838a75d7bd869a170f2767cb8f12`. +- `a129-disposable-provision-fp5wo6vy/proof/provision-result.json`, SHA-256 + `a07b93803acb47ed275393c96f320e8ca8c407ad99d897d7b81e1ef50a5c80ef`. +- `a129-scoped-reconnect-ti7n8bs3/proof/result.json`, SHA-256 + `5efebbbf3ddee482533835e9a0671d473ce9d02bbe2064fb65506f467b8a3c5c`. +- `a129-owned-console-diagnostic-gqwvmz78/console-screenshot-result.json`, SHA-256 + `a61411a50f2574c837a1ca23cb6e4ecaf361c24ebb289d92fe7da54ebcfd35fb`. +- `a129-owned-qemu-backtrace-2_iu1f5z/result.json`, SHA-256 + `00ae08c3b6da28bb394ad5bc793763a16534ac9deef96984ee93b727d530782b`; + its raw `backtrace.stdout.txt` hashes to + `5b4d5f68467d46c7acaabc3a05ac62039703e5711351171b378406a5075a95bc`. +- `a129-disposable-emulator-h4w8ds5l/proof/stop-result.json`, SHA-256 + `152926cb678b069e1409796951bf65f29d333a0b55cfce90c90d25d083df2d9a`. + +The exact owned supervisor is retired, both child processes are reaped and all owned ports close; +pristine cached images and existing AVD metadata remain unchanged. No stopped fixture is reused. +This failed disposable calibration leaves the original 64-item **10 Pass / 22 Partial / +32 Pending** ledger unchanged. Next work is fresh A129/A130 native app/phone acceptance and the +remaining lifecycle/notification/hosted-backend obligations; it is +not another blind emulator reboot or reconnect. Regular Desktop deployment and a PR remain separate. + + + +### Native history scrolling implementation (A129) + +At `ce1121ba`, actual Kotlin relay calls through the approved E2EE host handler reproduce +foreground wheel writes on native/session-host backends with mouse reporting off. Up three and +down two notches produce five SGR wheel writes at explicit byte-recorder boundaries while +retained history remains available. The real native emulators and session-host/client are +exercised, with a component host PTY adapter; this is not full Desktop, WebView gesture, kernel +PTY, physical ConPTY or phone proof. Receipt: `/tmp/nodeterm-native-scroll-triage-ylpvdie0/receipt.json`, +SHA-256 `c5d50310e539ad02d1c825ac12d19865b4c488184d1d50a8c809aad44b7cbb1a`. +The finding above remains historical. [A129 is source-fixed](android-audit-2026-09.md#a129) in +`0818bbed` and `2d693263`, published with the passing mandatory gates and exact-head CI at +`1add0408`. Actual app/WebView and physical acceptance remain unverified. `pty.attach` +advertises `scrollV1:true` only when the host serves the safe route. `pty.scrollV1 {streamId, dir:"up"|"down", lines:1..20, viewId?}` returns +`{status:"history", viewId, offset, totalRows, cols, rows, olderTruncated, hasOlder, hasNewer}`, +`{status:"input"}`, or `{status:"refused"|"uncertain", message}`. Each row has printable `text`, +`isWrapped` and `section:"normal"|"alternate"`; it is data, never bytes to feed to the terminal. +Three physical rows move per notch. The exact stream owns one immutable capture bounded to +**1 MiB / 8192 rows**, including pre-attach normal history and the current alternate screen; +pages are bounded to **256 KiB / 200 rows**. All viewers share a **16 MiB** budget, each view +expires after **60 seconds** without activity, and the per-stream FIFO admits at most **32** +pending scroll actions. Expired/foreign tokens refuse rather than adopting another capture. + +A valid `viewId` pages only those stored rows, even if the live application changes its mouse +mode. Live, actual user input, resize and explicit new intent invalidate the client token; +a new intent omits `viewId`. On that new intent, the actual headless xterm **6.0.0** tracking +and encoding are read behind queued output/geometry. Mouse-off native history writes zero PTY +bytes; requested default, SGR and pixel mouse encodings use the actual encoder, or a named +refusal when state is unavailable. Session-host `scroll-view-v1` independently negotiates +`scrollViewV1 {name, generation, up, lines, capture}` with the original subscribed socket and +exact session generation. Direct native and tmux paths bind the captured live viewer. +A mixed operation can write mouse input, so lost receipts stay uncertain with no replay or +generic name-only fallback. Known tmux and direct-SSH +wheel, copy-mode, gesture and momentum semantics keep their earlier provenance. + +Android keeps the shipped renderer xterm **5.5** parsing live output; history is a separate +inert display. Actor, page and display epochs reject late pages after input, resize, Live or +lifecycle changes. Automatic terminal reports remain ordered live input without discarding the +history view. Copy and link actions read the rows currently displayed. Host, Android and +actual producer interop are part of the same change; iOS adoption of the additive capability, +pages and invalidation policy is owed to **@eneskirca**. + +The leaf/backend checks above do not prove +physical ConPTY, kernel PTY acceptance, full Desktop/WebView interaction or phone acceptance. +The revised-policy root proof above has 13 passing control/restored tests and 18 assertion-caught +mutations. Focused Android control/restored runs pass 99 methods/11 suites; its 14 variants are +13 behavioral mutations and one posted-Runnable source pin. Separate CI-reader checks pass +11 methods and catch eight configuration-input deletions. Android proof index +`a129-android-proof-l9kmrcc0/index.json` has SHA-256 +`4ceeaacb141c1fc218644676d4287c15ed325f5d0b29a98fbea3fd36629242af`, binding receipt SHA-256 +`40d857136369dc851bcd510dbc719fe01339324ed9600bea3652d31914040494`. The CI-reader index +`a129-android-ci8-proof-p6uyz7nw/index.json` has SHA-256 +`f39a53b7b4f37135f923a5019660feade9701d82addbbfebb34fb3d5b82a11fd`, binding receipt SHA-256 +`749ea7fcb73f29b59120753b4c1c4a0f9efc071ce5c920ca9dbc933f5e799c2c`. The separate fixture-cleanup +receipt `a129-fixture-cleanup-proof-4ev5ts54/final-result.json` has SHA-256 +`a45cf3c36858b038978425a6a70af0f865a546cc0847b258361238c2177f4deb` and passes one control/restored +test plus one assertion-caught mutant. These separate scopes receive no combined mutation total. +Published `1add0408` passes the fresh 1012/105 protocol, offline app, 568/52 affected Vitest +(exactly four existing Windows process-tree skips), full TypeScript and all-five/ten-step +Android CI `37602046133` checks. At that earlier checkpoint, signed beta 18/code 19 was ready +and not installed on the intended phone; installed beta 17 and the physical checklist were unchanged. +[A130](android-audit-2026-09.md#a130) separately records the historical held Live-button +failure and focused source fix; neither source-level proof is actual WebView acceptance. + +### Composed Send backend follow-up (2026-10-07, A128) + +`PtyManager.submitComposed` routes an attached local stream to its tmux receipt, captured +`NativeWindowsPane`, or captured `SessionHostPty`. The outer `pty.submitComposed` wire contract +and Android completion policy remain unchanged. SSH-project relay input still refuses this +operation; direct Unix SSH retains its separately attested tmux path. + +Session-host protocol v2 adds the independently negotiated `composed-input-v1` feature and +`prepareComposedV1`, `writeComposedV1`, `cancelComposedV1` requests. Preparation mints a +single-use ticket bound to the live `HostSession` generation and actually subscribed socket. +It expires after 10 seconds. A lock spans paste and its later Enter across all subscribers. +The actual emulator's output barrier determines bracketed paste mode; the host rechecks the +same live generation, subscriber and ticket before each native write. Both client and host +enforce the 150 ms minimum. Cancellation, expiry and detach consume the ticket without input. + +The client captures the original subscriber registration, session generation and transport. +It checks them inside the actual deferred send turn, including a provably unwritten retry. +A ticket cannot be redeemed over a replacement socket. After a paste frame may have been sent, +missing receipts and RPC exceptions are uncertain, including server errors; they never authorize +a replay or an Enter on a replacement. Only a structured before-write refusal is a refusal. +Busy/stale/failed-attachment messages describe the current action; only an absent negotiated +feature tells the user to update the computer. An older running host retains its sessions and +refuses the new operation without a forced restart or name-only input fallback. + +The direct native Windows adapter uses the same ticketed writer with its captured PTY lifetime +and actual emulator barrier. This is explicit terminal input, separate from agent-message +process attestation and observed-screen submission. Raw keyboard/report/wheel semantics remain +unchanged. A positive receipt means submission through the owned terminal API, not application +execution. The timing bound is between native write calls; ConPTY can queue/coalesce writes, +so this source result does not prove Windows kernel delivery spacing under backpressure. + +The independently reviewed frozen candidate passes **181 affected Vitest tests / 17 files**, +full TypeScript and **19 focused actual Gradle methods**: five composed relay methods (including +the two actual backend producers), one executable CI-registration reader and 13 workflow-path +checks. **26 distinct mutations fail assertions**. The 21 initial semantic variants have 47-test +control/restored runs; the final disconnected-capability guard adds one catch with 48-test +control/restored runs, with the exact single production-line delta checked. Four deletions of +the newly required CI registrations fail the actual compiled reader, with control/restored passes. +Failed infrastructure/startup attempts are excluded. The separate final Linux kernel-PTY proof +binds the actual backend/client/socket and records complete framed/plain bytes, one raw Ctrl, +151.038 ms between native writes and 150.844 ms between the corresponding kernel reads in its +measured case, plus no Enter/replay after retirement. These are bounded component results. + +Durable private evidence: `/home/mgysin/.cache/nodeterm-android-work/a128-alt-backends-evidence-ic6w1072/archive.json`, +SHA-256 `60bf42315e6b945e27f7b04c57ae5f58205bac2606ab94729c57895a99ca4cca`; +its 91 byte-identical archived evidence files and 21 source files retain the original aggregate +`0a10d94e817caf932588d6400f031553c72daccb7d901139a46dd9212a75cfc7` +and exact patch `4bdf58a3ad0d24da1a45be84dc96c2a3e37416ffb0aafb2c0ca2d497714996c6`. +Mandatory merged full protocol/app and affected Desktop gates are run for publication; +exact-head CI is checked after every push. +The actual producer-to-Kotlin tests use the production backend/client/emulator and encrypted +relay with an explicitly labeled native byte recorder. A separate genuine Linux kernel-PTY and +session-host/socket proof covers delivery. Neither substitutes for physical Windows ConPTY or +phone acceptance. Remaining device checks include lifecycle, held/lost acknowledgements, +changed host keys, notification actions/permissions and usable live-provider authorization. +The original 10 Pass / 22 Partial / 32 Pending ledger stays unchanged. iOS **@eneskirca** can +adopt the additional availability with the same public action/result and retention rules. + +### Pixel 7a beta 17 upgrade and composed Send (2026-10-06) + +This is a completed, bounded nine-case repaired-device checkpoint. The intended Pixel 7a runs +Android 17/API 37 and Vanadium `154.0.8037.126.0`. The fresh isolated Desktop and installed +`0.1.0-beta.17` / code `18` use signed source +`a79375c3e87a6294ac04de3b94bc11ec15afb6f5`; no regular Desktop or original workspace is changed. + +Exact merged full forced offline checks execute **982 methods / 102 suites**, with zero +failures/errors/skips; offline app Kotlin, **187 affected Vitest tests / 11 files** and full +TypeScript pass. [Android run `37523703627`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37523703627) +passes all five jobs and ten critical steps for that exact head. The beta-17 APK SHA-256 is +`c51433d4d52d85a5a81a539dbeeb2140745ececc01543577a7681f3d6921adb1`; +the retained signer certificate SHA-256 is +`c610c3a0b637a8005b6fd858587e8cbf5260622648bc1cd97c0de0ab5f146dbd`. +Actual signing/verification tools are SDK 36; SDK 37 tools are unavailable, so no current +SDK-37 artifact verification is claimed. + +The explicit same-signer `adb install -r` update takes **5.48 seconds**. The installed public +APK pull exactly matches the prepared hash; version code rises from 17 to 18 and the +first-install time is unchanged. The saved temporary SSH profile reconnects to the correct +Sessions after update without a pin edit or public-key readmission. This verifies that one +Pixel 7a profile/update path, not the paused original Pixel 10 Pro's paired migration matrix. +The Settings public-identity fingerprint matches the pre-update public key; no Keystore/private +key extraction is used. Pin reuse is established practically by this saved connection, not by +a changed-host-key enforcement test. + +**A128 emacs case passes:** one explicit Send from tmux copy mode reaches the original pane +exactly once by native counter/output; copy mode exits and original producer and sibling guard +remain unchanged. The native emacs mode observation precedes draft entry; the vi case below +samples active copy mode immediately before Send. **A127 viewer-only case passes:** only viewer PID 1276/birth 9267123 is +terminated, producing the neutral exit-1 connection notice with Send disabled and draft retained. +Manual Reattach creates viewer PID 2129/birth 9288801 onto the same producer PID 1127/birth +9266970; the entire native `50x40` capture is unchanged. The retained draft then submits once. +Viewer termination is separate from SSH transport loss or producer death. + +**A128 vi transport/Send case passes:** the captured phone SSH handler is interrupted while +only the owned SSH listener is paused for 15 seconds and then resumed. The disconnected UI +retains the draft and disables Send. Automatic reconnect creates viewer PID 2597/birth 9299680, +retaining the same producer/guard and byte-identical complete `50x40` native capture. Copy mode +remains active; its scroll position changes from 70 to 31, so exact position preservation is not +claimed. One explicit Send then exits copy mode, increments the native counter once, produces +one output line and retains all 1800 markers while clearing the UI draft. + +**Raw Ctrl case passes:** with Ctrl armed and `c` entered, one Send reaches an owned raw TTY +consumer as exactly hex `03`; a bounded 0.8-second extra-byte window observes no framing, +Enter or other bytes. The UI clears/unarms. This does not establish other control combinations +or agent behavior. + +**Managed shell and exact owned SSH End pass:** actual shell-radio selection and Start create +and register the target plain shell through the phone. End later removes only its pane `%1`, +producer and viewer clients while the sibling guard `%0` retains its exact identity/history. +The canvas node remains, as the SSH End flow describes; this does not prove relay canvas removal. + +**Cleanup passes:** only the temporary profile is forgotten through app UI. The exact admitted +public-key line is removed, restoring the fixture's original authorization file. The owned +nonce-validated intentional host stop reports app/server exit, unchanged source/tool/build +bindings, removed generated private authentication and both owned listeners closed. This is +controlled supervisor cleanup, not ordinary native Quit. The phone returns to Computers with +beta 17 installed and its public identity unchanged; the regular Desktop/original workspace +and other user terminals remain untouched. + +The frozen owner-only physical receipt is +`nodeterm-new-phone-fixture-q5v1nuem/proof/physical-pixel7a-beta17/qa-report.json` in the Android +work cache, SHA-256 +`ece4fa48d31fab55a9f5158f475b4415e9c116c8a2f1683ec48295bf42e55e6b`, +binding **303 raw-artifact hashes / nine bounded Pass cases**. Earlier helper refusals and an +intermediate overlapping vi preparation remain separate from the final pinned vi case; neither +is a new product finding. The preceding beta-16 negatives and cleanup remain historical below. + +Independent read-only review verifies all 303 raw hashes, 2480 source inputs, 136 compiled +Desktop artifacts and the scoped device/cleanup results without material findings. Its separate +receipt is `beta17-independent-physical-review-nvw2zkih/review.json`, SHA-256 +`8f2ad01eefe57449498dd9d789d1818d037408f3a827c4a9d03a8d30127a106b`. + +Changed-host-key enforcement, deterministic held-receipt edit/rearm/stale-completion races, +uncertain-ack retention, discriminating phone paste/Enter timing and wider Compose/WebView/provider +cases still need their own physical acceptance where admitted; existing isolated source/native +tests remain separate. No full 64-item checklist, live-provider question/approval/notification, +relay/QR/cellular, real PAM, host reboot/power loss, macOS/Windows or SDK-37 artifact acceptance +is claimed. The original **10 Pass / 22 Partial / 32 Pending** ledger is unchanged. A25/A93 still +need hosted-backend source. Later docs-only publication gates/CI are distinct from this installed +`a79375c3` APK/runtime; each published head owes its own checks and private receipt. + +### Composed Send source checkpoint (2026-10-06, A128) + +`7e7d2c04` fixes the physically reproduced beta-16 input loss; `5484ab9f` registers its four +host suites in private-beta CI. The input bar awaits an explicit submission through the captured +viewer's input actor. It stops JavaScript momentum, discards unsent scrolling and waits behind +the reserved in-flight scroll. The host cancels history mode only on the exact captured +viewer/pane generation. Nonempty paste uses tmux-owned bracketed framing; Enter is a separate +write after 150 ms, with a fresh identity/lifetime guard. Any failure after paste is uncertain. +Ctrl submits one raw control byte without framing or Enter. Payloads use private stdin/buffers. + +One-shot completion clears the draft only after a delivered receipt for the same current viewer +and unchanged draft revision; editing away and back to identical text preserves the new draft. +A newly rearmed Ctrl is preserved. Refused, uncertain and stale outcomes retain input without +replay. Synchronous WebView preparation failure also retains the draft. Delivery means input +submitted to the attested pane/PTY, not proof that its application executed the command. +At that source checkpoint, direct Unix SSH and local-tmux relay hosts support this operation; +legacy/unverifiable, native Windows/session-host and SSH-project relay routes explicitly refuse it. +The [2026-10-07 follow-up](#composed-send-backend-follow-up-2026-10-07-a128) adds the two local PTY backends. + +The frozen candidate passes **170 distinct affected Kotlin methods / 8 suites**, **187 affected +Vitest tests / 11 files**, full TypeScript and forced offline app compilation. **26 isolated +source mutations** fail assertions, with passing controls/restored runs: 17 Android +transport/preparation/completion mutations and nine host mutations. Actual private native +SSH/tmux fixtures cover emacs/vi copy mode, exact-pane/control input, separately timed Enter, +retirement and uncertainty; the restored host control passes 42 tests including 11 native cases. +The CI registration follow-up passes **nine actual Gradle/JUnit configuration methods**; +deleting each of the four new host registrations fails an assertion, with control/restored runs +passing. This gives **30 assertion-caught mutations** across source and CI registration. + +Private aggregate V2: `a128-composed-submit-candidate-elbunqup/a128-composed-review-v2.json` +under `/home/mgysin/.cache/nodeterm-android-work`, SHA-256 +`34a0044207b64b5627f0a8653a9969234430a51b697d8ef589220f9e9a26b68f`. +CI registration proof: `/home/mgysin/.cache/nodeterm-android-work/a128-ci-coverage-tzs459s8/proof/receipt.json`, +SHA-256 `6f5b956e6c4ff2d9c0f8a6c070ba429b1cfe8c1175852c56bf30be159c5720f9`. +Independent read-only source/evidence review finds no material issues. These source, +configuration and isolated native-fixture results remain distinct from the subsequent +[beta-17 checkpoint](#pixel-7a-beta-17-upgrade-and-composed-send-2026-10-06): merged 982/102 protocol and app +and affected Desktop gates pass, exact-head CI is green and beta 17 is installed. The single +emacs/vi composed-Send, controlled vi transport recovery, raw Ctrl and A127 viewer-close/ +manual-Reattach device cases and exact owned End/cleanup pass. Broader physical/provider cases +remain pending. The original +**10 Pass / 22 Partial / 32 Pending** ledger is unchanged. + +### Pixel 7a lifecycle, scroll and input follow-up (2026-10-06) + +The intended Pixel 7a runs the retained-signer, non-debuggable beta 16/code 17 from `2723845e`: +APK SHA-256 `9c5309c8cfb225daf59f7fac70be1760fb0abdaac36cb55005b958f2186c8ce3`. +Android 17/API 37 and Vanadium `154.0.8037.126.0` are observed device facts. The cached Desktop +build is `59e4c93e`; `6c570d6a` changes only four documents. Android production behavior is +equivalent to that checkpoint except for a class comment. This is baseline verification, +not a phone pass for A127/A128. + +Manual high-port SSH with an explicit private profile and a compared host fingerprint works +with Remote access off. Actual videos capture Creating before Back and Home; each produces +exactly one registered plain shell in the selected local folder. Reopen and a real background +app-process kill/restart retain the saved SSH profile and original pane/cwd. Both original and +new shells remain distinguishable by their native pane/process generations. + +Custom wheel bindings `-N7` are observed in both emacs and vi copy tables. Held older-history +drags move repeatedly before finger lift; a free flick continues from native position 784 to +1029 after the ADB swipe returns. A new touch supplies a bounded stable interval below the +history boundary; release can reposition the cursor. Native samples are aligned to ADB command +bounds, not MotionEvent timestamps. These checks establish neither FPS nor instantaneous stop, +the wider reversal/gesture matrix, or restoration of the original bindings: that pre-read +failed, and all modified options disappear with the disposable fixture. + +Dropping only the phone's owned SSH handler disables Send and preserves its draft while the +app automatically reattaches to the same shell with all 1800 generated history lines retained. +Find sees an offscreen original marker among 1805 retained lines. SSH End removes only the +owned Home shell; the guard and other shell survive, and canvas nodes remain as the SSH dialog +describes. This does not verify relay canvas End. + +Two physical failures become [A127](android-audit-2026-09.md#a127) and +[A128](android-audit-2026-09.md#a128). Terminating only the viewer reports exit 1 as session death +despite the live producer and retained history. Composed Send in history mode clears the draft +without a command/marker, both after vi-mode reconnect and on a healthy emacs-mode connection. +Automatic transport recovery passes; composed command delivery in these cases fails. + +A127 is source-fixed in `0d50075a`: every numeric viewer exit now describes the closed terminal +connection. Retry conditions, budget, generation checks and Reattach remain unchanged. Three +JVM methods, offline app compilation and twelve compiled actual-callback cases pass; three +isolated mutants fail assertions, with passing control/restored runs. The callback fixture uses +immediate Handler, viewer-slot and retry-admission fakes, not Android lifecycle or native liveness. +A128 is now source-fixed in `7e7d2c04`, with CI registration in `5484ab9f`; the +[source checkpoint](#composed-send-source-checkpoint-2026-10-06-a128) records its separate checks. +At this beta-16 baseline checkpoint neither source fix had an updated-APK physical pass; +the separate beta-17 checkpoint above records the later bounded repaired-device cases. + +The temporary phone profile and its displayed public-key admission were removed. The exact +owned fixture exits successfully with generated private authentication removed and both +listeners closed. Its controlled nonce stop uses SIGTERM for app/server cleanup; ordinary +app Quit is not verified. No regular Desktop, other terminal or phone identity was changed. +The original Pixel 10 Pro ledger stays **10 Pass / 22 Partial / 32 Pending**. Relay/QR/cellular, +provider-authenticated launch, notification/permission paths, phone reboot and wider platforms +still need their own acceptance. + +Private physical receipt: `nodeterm-new-phone-fixture-ise429xt/proof/physical-pixel7a-w1gr_uz5/qa-report.json` +under the owner-only Android work cache, SHA-256 +`dfce110a6c2fb9d4ed8497bfc2ac6bf59311766a7bc36b866ace1d0dcb2d8408`, binding 125 raw artifacts. +Its `sdk37ApksignerVerified` field lacks current SDK 37 tool/log provenance and is not relied +upon. A separate correction preserves that frozen report and records the actual current SDK 36 +v2/v3, single retained-signer verification: +`pixel7a-signature-provenance-correction-ox2gec9p/correction.json`, SHA-256 +`374244d6b231f1bcd0932515010215bd8683d1a6795b3528aa18522883977fde`. +A127's private proof receipt is `/tmp/nodeterm-a127-proof.json`, SHA-256 +`75069aa576ae694cd1b7b8248a9b81c2364882eb37a00d1ccabb93157bf49f67`. + +A125 is source-fixed in `415dae9b`; A126 is committed in `5b7286b3`. +Eight actual Linux Desktop **Canvas/Modal × soft/hard wraps × inner none/SGR mouse** cases +pass at `5b7286b3`. Each loses one birth-pinned product ControlMaster, creates a new master/ +selected local viewer and retains its remote pane/server/producer/cwd and complete same-grid history before, +during, after and at final observation. Both trusted actual textarea `g` inputs are acknowledged +on the old/new PTY: **16 ACKs**, **zero unexpected bytes**, one producer paint per case and +**16 complete URL activations**. Native hover/leave and exact owned cleanup pass. Modal cases +use `80x24`; Canvas cases use `49x48`, with each history comparison bound to its own grid/case. +The historical `11e22453` stale-Modal negative and first `415dae9b` control retain their original +provenance. The latter creates replacement viewers but records 18 unexpected DA1/DA2 bytes after +ACK 1; ACK 2 is not attempted. Its unclassified wrapper/runtime exits 1/2 remain separate from the +later A126 diagnosis. Those failed controls are not rewritten as repaired-source positives. +**294 unique affected tests / 17 files** and full incremental TypeScript pass. **15 isolated +mutants (six A125 + nine A126)** are caught by assertions; each fix's control/restored runs pass. +Forced offline checks at `5b7286b3` execute **950 tests / 98 suites**, zero failures/errors/skips, +and both protocol/app Kotlin compile tasks. These are disposable QA hosts; no new APK or regular +Desktop installation is included. Publication requires fresh offline gates and all five Android +CI jobs for the exact published HEAD; its private bundle and final report carry that run's result. +See [the reconnect notes](#desktop-card-reconnect-repair-2026-10-06) and +[audit A126](android-audit-2026-09.md#a126) for receipt hashes and the remaining scope. +Actual Canvas mounting and selected-view native keyboard recovery pass in the eight-case matrix; +one additional two-host soft/none case verifies a quiet park/adopt cycle and inactive global-card +recovery with the active host unchanged. See [the bounded two-host follow-up](#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06). Remaining Android release acceptance needs a +reachable intended phone for lifecycle/outage, answer/notification/permission and live-provider +checks; A25/A93 need hosted-backend maintainers. Further host coverage includes streaming/expiry/ +eviction while parked, repeated or broader outage/backoff, Server/cross-window response ownership, +GPU pressure/full-frame display output and macOS/Windows. The synthetic key-only hosts do not +establish real-user PAM, power-loss, agent-launch or ordinary Quit behavior. Unsupported private +xterm shapes warn and retain input but may duplicate replies; legacy onBinary is unchanged. +No Android wire/file or iOS client change is required. The original Pixel 10 Pro ledger remains +paused at **10 Pass / 22 Partial / 32 Pending**. + +### Inactive global-card owner and bounded park/adopt (2026-10-06) + +One actual Linux Desktop two-host case passes at source/build +`59e4c93e5b1256e9882205700d4907a5d43a8e20`, tree +`0f4753c65b858003a0d1560c3cf278cbffb48fd0`, in **27.39 seconds**. Two distinct private OpenSSH +hosts have independent owner projects, client/host keys, remote PID namespaces and private +home/tmp mounts. The namespace setup is source-reviewed; no separate raw mount-namespace probe +is retained. Each +remote plain-shell producer paints once; both panes stay `80x24` with soft wraps and inner mouse +reporting disabled. This extends native acceptance of the existing A125/A126 repairs; it adds no +product repair, regression-test count, mutation count, APK or regular Desktop installation. + +The actual B global-board card stays open through ordinary native Ctrl+K project switches +B → A → B → A. Before the outage, B's detached parked terminal and its adopted terminal preserve +the same installed xterm/core, input/focus wrappers, ordered subscriptions and API binding, quiet +whole renderer buffer, native SSH client/master and remote history. Its SID provenance is the +actual primary create and acknowledged native Canvas `g1`, plus the carried original writer and +subscriptions with no create/detach/client replacement. Canvas keeps that SID in a lifecycle +closure; no current/adopted React SID ref or post-adopt Canvas-input result is claimed. + +With actual A active and its Canvas mounted, inactive B's Modal receives native `g2`. One +birth-pinned B-only product ControlMaster SIGKILL then triggers automatic recovery. The same +open B card warms a fresh selected Modal viewer/session on B's new master and acknowledges native +`g3`; there is no inactive-B Canvas create or launch replay. All **3 native `g` ACKs**, **2 complete +wrapped URL activations**, hover/plain-click/Ctrl-click/leave controls, zero unexpected input and +one paint per role pass. B retains its pane/server/producer/cwd and complete same-grid history +before, during, after and at final observation. A's master/client/pane/producer/grid/history, +zero input and owner routing stay unchanged within the acceptance window. Exact owned observer, +session/master/daemon cleanup and generated-auth removal pass; explicit fixture exit supplies no +ordinary-Quit proof. + +Private proof: `~/.cache/nodeterm-android-work/nodeterm-linux-ssh-global-owner-6lens2kd/proof/`. +`receipt.json` SHA `e923f8fca25e05aa9869ab3bd22e4b2444147e41621c2bf0a8ca38465ece78c3`; +`runtime-result.json` SHA `313413ea48f371b6fbc62e2d03c14af7f0b0fcd0c573110c9d18137f0da13adb`. +Root acceptance: `~/.cache/nodeterm-android-work/a125-a126-global-owner-root-mq6v7w7z/root-acceptance.json`, +SHA `7cb20c71a52dfc3bf968073170867b6e7ff199957e23a664a5ffebb5e3ab453d`. +Independent review: `~/.cache/nodeterm-android-work/ssh-global-owner-independent-review-kn_5dsw4/independent-review.json`, +SHA `7fc9ace6926a5004ee5a8c817d664fde59f8cf6d83d26a9e1ca5fa3a03cd869a`. +The exact-source full build binds **2,465 tracked inputs / 136 output artifacts**; snapshot SHA +`dc188476bfa29bdf5c71f02a20582a843cdb7a30455dfa42b748adcee939be65`, build receipt SHA +`278094c8102eed6cf5e191cbbcf4ec861b518158c810f9f3ee46ce6a0de4f377`. Source/runtime/held outputs +stay unchanged. This quiet cycle does not establish streaming while parked, expiry/eviction, +foreign-host attachments, Server/cross-window ownership, broader outage/backoff, GPU stress, +phone/provider or other-platform behavior. The paused physical ledger is unchanged. + +**Fresh Pixel 7a verification and A124 (2026-10-05).** This separate phone runs Android 16 / +API 36 and Vanadium WebView `145.0.7632.120.0`. Fresh installation of private beta 16/code 17 +matches APK SHA-256 `9c5309c8cfb225daf59f7fac70be1760fb0abdaac36cb55005b958f2186c8ce3`; +signature verification and the non-debuggable/run-as refusal check pass. This is fresh-install +proof, not the original Pixel 10 Pro's update/migration result. + +Normal manual SSH uses an explicit profile on a disposable shared Desktop/sshd host. The +app displays the actual host fingerprint, refuses authentication before the public key is +installed, and adds/pins the host only after authentication. Reapplying the displayed public-key +installer preserves one phone key and the selfcheck key with private file modes. An existing +owned Desktop terminal receives phone input exactly once in the expected project cwd; retained +Unicode history is visible. A continuous held drag advances copy history from 10 to 135 lines; +after a flick returns, momentum advances 295 to 490. An early new touch stops at 510 while the +grid stays `50x43`; the later long-touch IME resize is recorded separately. The first wrong-direction +scroll attempt was inconclusive and receives no pass. These are bounded gesture checks, not FPS +or a complete 64-item device run. Manual high-port SSH supplies no QR, port-22, relay or cellular proof. + +**Original trigger correction:** `managed-terminal-open.xml` records **New session → Start** +with default Claude still selected; tapping the nonclickable option text did not select Terminal. +The real A124 failure is shared absolute-shell validation before any agent CLI lookup. On the +repaired host, repeating the wrong-label selection correctly reports that the Claude CLI is +unavailable; that is a verifier correction, not a new finding. + +With the actual shell radio selected (`repaired-shell-selected.xml`), the repaired frozen Desktop +`40731381` creates and registers `/bin/bash` node `term-muvrrd9n-623cda88cddd8ded`, pane PID **1101**. +Phone input executes exactly once in the chosen project cwd; the original node +`term-muvr91rh-12595648`, PID **368**, retains its marker and receives no managed input. +Closing/reopening the viewer succeeds. Restarting only the Android process (**9258 → 14735**), +without resetting app data or identity, reconnects the saved SSH host to the same pane and shows +the previous marker. This is one manual high-port SSH plain-shell case, not live-agent, managed +End, power-loss or QR/relay/cellular verification. + +After 150 additional output lines, **Find** reports two matching lines in 155 host-retained lines. +An actual native tmux `-S 0` screen capture excludes the old marker, independently proving the +matches are offscreen; an earlier recent-output capture's misleading live-screen field is +corrected by `repaired-history-native-screen.json`. Selected-line copy pastes the exact marker +into the input draft. The local **Copy** sheet opens; its offscreen-marker query correctly returns +“No matches in captured output” because it searches its current local buffer. A public URL appears +with Open/Copy offers, and URL Copy pastes the exact URL. No external browser-open result is claimed. + +Both owned QA host profiles are forgotten through the app and the phone returns to the initial +Computers/Add screen (`phone-qa-cleaned.xml`). Beta 16 remains installed and the global phone +identity is preserved; viewer detach/Forget leave both owned panes intact until fixture shutdown. +The repaired V5 host at clean `40731381` then exits successfully with graceful requested stop, +app/sshd exit, closed listeners and generated private-auth cleanup. The earlier host's negative +source-binding receipt still records the A124 edits and is not replaced by this clean-host pass. + +The repair's 38 affected Vitest tests, full TypeScript and three isolated assertion-caught guard +mutations pass. The exact `40731381` offline protocol gate is **949 methods / 98 suites**, with +zero failures/errors/skips; five Windows/platform Vitest cases skip on Linux. Its frozen full +Desktop build takes 29.3 seconds. All five Android jobs pass in the first-push exact-head +[run `37375870878`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37375870878). +Physical evidence: `.nodeterm/android-pixel7a-device-y0ia4hm_/physical-repair-result.json` and its +named registration/input/restart/history/clipboard/cleanup receipts. Earlier installation, manual +SSH and gesture receipts in that directory remain separate. Source proof: +`~/.cache/nodeterm-android-work/a124-executable-mutation-lhf2f_p6/proof/`. +The original Pixel 10 Pro beta-10 ledger stays paused at **10 Pass / 22 Partial / 32 Pending**. +No wire/file contract changes; the shared host repair benefits iOS too (@eneskirca). + +**Latest native follow-up (2026-10-05).** A120 passes four cancellation/deadline cases +(76 case-scoped assertions) and a separate 17-check synthetic installed-Fedora-PAM control. +See [the receipt and its limits](#native-setup-timing-installed-pam-and-remaining-desktop-checks-2026-10-05). +The later GPU-enabled eight-case terminal matrix, native File-menu Quit, outside-terminal Ctrl+Q +and one real tmux Canvas pilot pass. A122 fixes focused-terminal native Quit; its four Linux +native cases pass. Later eight-case tmux and eight-case SSH matrices, separate GPU recovery pilots +and A123 native cleanup control/mutant/restored checks pass; see [the follow-up](#tmux-ssh-and-gpu-recovery-follow-up-2026-10-05). +The later Server retirement pilot, derived Desktop compositor cell comparison and single Desktop +SSH reconnect pilot pass. Repeated/ +page-wide GPU pressure, full-frame compositor/display output, broader SSH reconnect and macOS/Windows remain unverified. +These checks do not change the paused phone ledger or prepared beta-16 APK. + +**Historical prepared private beta 14 (2026-10-04).** `0.1.0-beta.14` / code `15`, clean built source +`facbbd0384bb5b0ee91656f364793a12f8f41eb1`, adds A113 truthful revoke outcomes, A114 dictation language, A115 exact host/session +retirement and A116 saved pairing/LAN adapter choice. Full offline protocol checks pass +**890 tests / 92 suites**, with zero failures, errors or skips. +Offline app compilation, **1628 affected Vitest tests / 71 files** and full +TypeScript checking pass. Only the ten existing Windows/macOS runtime cases are skipped on Linux. +The minified offline release build passes in **47.96 seconds** with the retained private +signer and R8/runtime keeps. Independent SDK 36/37 signature, nondebuggable manifest, payload/ +provenance and 16-KB ZIP/native checks pass. + +The private archive records **158 assertion-caught isolated mutation variants**, +with passing controls/restored runs and each receipt's source bindings. One additional redundant +retired-guard variant survives equivalently and is recorded separately, never counted as caught. +Historical/intermediate bindings stay separate; full merged gates verify the release source. +Actual local HTTP and producer→Android tests use synthetic OS/key facts; HostStore tests use +in-memory Android interfaces. Native wiring source guards and cached compilation do not prove +physical recognition, VPN reachability, relay revocation or replacement behavior. Earlier beta +proof remains historical. APK SHA-256: `3c75912a5b7bb561fa9a0720be5cb11be75e1672f98379e4745871bf2729f8f6`. Artifact: +`.nodeterm/android-beta-14/nodeterm-android-0.1.0-beta.14.apk`; private proof: +`.nodeterm/android-beta-build-14/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone testing is paused; the ledger stays **10 Pass / 22 Partial / +32 Pending**. No PR opened; A68 stays deferred. Each push requires its own exact-head green +Android workflow. At this APK source checkpoint A13 direct-SSH Desktop detachment, full legacy +identity association and A25/A93 hosted backend dependencies remained open. The later A13 host +follow-up below closes the coattachment source gap; its physical checks remain pending. + +**Historical prepared private beta 13 (2026-10-04).** `0.1.0-beta.13` / code `14`, clean built source +`ba64f6289c0f552b85bfc17f99aee879aaf45975`, includes A111 managed SSH New and A112 producer-input coverage, +along with the beta-12 changes. Full offline protocol checks pass **845 tests / 87 suites**, +with zero failures, errors or skips. Offline app compilation, **1433 affected Vitest tests / +60 files** and full TypeScript checking pass; the ten existing Windows/macOS runtime cases +are explicitly skipped on Linux. The minified offline release build passes in **47.77 +seconds** with the retained private signer and R8/runtime keeps. Independent SDK 36/37 +signature, nondebuggable manifest, payload/provenance and 16-KB ZIP/native checks pass. +The new managed-creation and input-coverage regressions catch **148 isolated behavioral mutation +variants**, with passing controls/restored runs and per-receipt source hashes. Receipts distinguish +assertion failures from the named runtime contract failure; earlier beta-12 proof remains separate. +An isolated native node-pty/Electron/tmux fixture verifies host cwd/account/hooks/env/policy, +one launch submission and uncertainty fences. It uses a fixture CLI; real Claude startup, +macOS native creation and physical power-loss durability are unverified. +APK SHA-256: `faf9e508fa82355f640512ec1861ba886d0e48b8aef3e724edc6435c9f8c134b`. Artifact: +`.nodeterm/android-beta-13/nodeterm-android-0.1.0-beta.13.apk`; build and mutation proof: +`.nodeterm/android-beta-build-13/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone testing is paused; **10 Pass / 22 Partial / 32 Pending** +remains the device ledger. Managed New and the other new source flows require physical checks. +Each push requires its own exact-head green Android workflow; no PR opened, and A68 stays deferred. + +**Historical prepared private beta 12 (2026-10-04).** `0.1.0-beta.12` / code `13`, clean built source +`b2e41255d8b2cf7bb342b78b7f686b8b0191879e`, includes A100–A110 and the earlier beta-11 additions. Full offline protocol +checks pass **815 tests / 83 suites**, with zero failures, errors or skips. +Offline app compilation, **1314 affected Vitest tests / 53 files** and full TypeScript +checking pass. The ten existing Windows/macOS runtime cases are explicitly skipped on this Linux +host; no additional skip is accepted. The minified offline release build passes in +**52.47 seconds**, with retained private signer, R8/runtime keeps and source/hash provenance. +Independent SDK 36/37 signature, nondebuggable manifest, payload and 16-KB ZIP/native checks pass. +The private support archive retains **236 isolated mutation variants** with passing controls and +restored runs: 228 assertion cases, six named executable-contract failures and two bounded timeouts. +Historical source hashes remain attached to their own receipts; this count does not claim that +all earlier worktrees were identical to the final merged source. Full merged release gates pass. +APK SHA-256: `d5a350a1f7c6f05f1ab476f25cbe58441eb666d61d1a4b62610a3faa2e058e1f`. Artifact: +`.nodeterm/android-beta-12/nodeterm-android-0.1.0-beta.12.apk`; build and mutation proof: +`.nodeterm/android-beta-build-12/`. **Prepared, not installed:** beta 10/code 11 remains the last +confirmed Pixel installation. Phone checks are paused, with **10 Pass / 22 Partial / 32 Pending**; +live Claude rule/question application and the new physical feature matrix remain unverified. +Each requested push still needs its own exact-head green Android workflow. No PR opened; A68 +remains deferred until a PR is requested. + +**Historical beta-10 delivery and paired update (2026-10-04):** `0.1.0-beta.10` / code `11`, +clean snapshot `e3ce041c752bdef11b378cf301c719a6e62f70ab`, is installed on the intended Pixel with the +unchanged private signer. The actual release build passes in 52.4 seconds with all R8 keeps; +independent SDK 36/37 signature, 16-KB alignment, payload and provenance review passes. +APK SHA-256: `04ace0ea01540bb477fcf823395a299fa211cfef3745aa28674099038ceb3693`. The 10.14-second +same-signer update preserves install identity, notification grant and the owned SSH second +pane. On beta 9, genuine desktop-issued code text re-pairs the complete original owned desktop +profile and receives relay credentials; safely anchored external SSH refuses, then Automatic +falls back to the actual relay. After updating, that saved +pairing reconnects without re-pairing; the own Terminal1 opens and input reaches its real scoped +PTY. **Items 1/36/51 pass; items 35/49 remain Partial, giving current 10 Pass / 22 Partial / 32 Pending.** Debug-key update/migration +variants remain conditional SKIP on this working private installation. The same owned production +desktop profile now runs repaired build `ec12ea9a`; the original failure was on `071735d6`. Local gates pass 689 protocol tests /67 suites with zero +failures/errors/skips (86.08 seconds wall time including a new daemon), offline app compile +(6.78 seconds), 54 affected Vitest tests and full TypeScript checking. The exact built source `e3ce041c` has all five CI jobs green in [run `37194375778`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37194375778). Each later documentation push needs its own green workflow. Private artifact/proof: +`.nodeterm/android-beta-10/` and `.nodeterm/android-beta-build-10/`; the canonical paired-update receipt is `.nodeterm/android-beta-build-10/paired-update-result.json`. Broader relay/managed, +cellular and the full device checklist remain open; installed A91/A94 full focused empty-host flow passes. + +**Relay creation, Rename and exact End verified; Refresh dispatch observed (2026-10-04).** On +beta 10, Sessions → New session → Terminal (shell) → Start creates and opens one actual +canvas-registered shell in the exact private project folder. Native input and pwd reach its +scoped bash pane. Rename updates the mounted desktop title without reload. Sessions Refresh +rediscovers both owned rows; the actual owned session-menu Refresh emits exactly one node-refresh +IPC, preserving pane identity and page time origin. Its repaint effect remains unverified. +Exact native End then removes only the adopted node/pane/PID birth; the original producer +terminal remains unchanged. The mounted Board also removes that card without reload. +Item 32's relay plain-shell variant passes; managed-account/cellular and other required variants +remain pending. Item 35 stays Partial for missing Wake and Refresh repaint evidence; item 36 +passes actual repaired-desktop move/remove/re-add/create-label checks (A95). Earlier first-round rotation restoration is historical; final restoration remains pending. The tally is 10 Pass / 22 Partial / 32 Pending; no full release/device pass +or final fixture/service cleanup is claimed. + +Native session-action proof is in the Oct4 checklist directory: +`beta10-owned-session-refresh-phone-result.json`, `beta10-relay-owned-end-phone-result.json`, +`beta10-relay-owned-end-confirmation.json` and `beta10-relay-owned-ended-sessions.json`. +The owned desktop fixture retains `beta10-session-refresh-observed.json` and +`release-qa-nodes-ended-1791111850721754129-abe6b2.json`, verifying exact IPC dispatch and +target removal with its original sibling unchanged. + +**Settings visible flow passes; item 49 remains Partial (A44, 2026-10-04).** On installed beta 10, +a temporary phone name and safe HTTPS loopback API persist after actual system Back and reopen. +An invalid HTTP address shows its field error after keyboard dismissal and a visually reviewed rejection toast; reopening +retains the prior valid HTTPS value. Reset to default, system Back and reopen restores the built-in +API; the original phone name/API are then restored and verified. Unedited leave/reopen preserves +the visible values. Internal preference-key absence and absence of writes were not physically +inspected on this nondebuggable app; existing `SettingsLeaveTest` behavior/source guards cover +those rules. The strict device ledger therefore marks item 49 Partial, giving 10 Pass / +22 Partial / 32 Pending. Native proof includes `beta10-settings-phase1-result.json`, +`beta10-settings-phase2-result.json` and `beta10-settings-toast-visual-review.json` in the Oct4 +checklist directory. + +**Final phone cleanup and background watch remain pending (2026-10-04).** The intended Pixel's +wireless ADB endpoint is no longer reachable; its current endpoint has been requested. The guard +refused before the final phone Forget and portrait-lock restoration, so neither action is claimed. +Earlier rotation restoration belongs to the first test round; current final restoration is pending. +The isolated empty SSH service is stopped. Phone name/API and system night mode `yes` restoration +are verified. No natural-periodic background Done watch has started and no result is claimed. +The remaining phone cleanup and notification checks can continue when the correct Pixel returns. + +**Phone system-theme compatibility passes (item 51, 2026-10-04).** On the installed beta 10 +Pixel, Computers and Settings native screens remain readable under system dark and light modes, +including status/navigation bars; four captured PNGs have a matching visual-review receipt. +The app retains its fixed dark palette in both modes; an automatic light app palette is not claimed. +Original system night mode `yes` is restored and verified. Tablet/foldable is conditional SKIP +because neither is available. Proof: `beta10-system-theme-result.json`, +`beta10-system-theme-restored.json` and `beta10-system-theme-visual-review.json` in the Oct4 checklist +directory. Item 51 passes, giving the current 10 Pass / 22 Partial / 32 Pending ledger. + +**A95 is fixed and verified on the rebuilt production desktop (2026-10-04).** The held +`071735d6` build reproduced stale mounted Board moves/labels. The same owned paired profile now +runs the repaired `ec12ea9a` production build; Android remains installed beta10/code11 (`e3ce041c`), +with no APK change for this desktop fix. Actual phone Move to Ungrouped, label removal, re-addition +and creation/application of a second blue label update both persisted state and the mounted +desktop Board without a reload. Baseline and every resulting DOM snapshot share page time origin +`1791111312961.1` and match persisted state. This completes item 36. Nine affected Vitest suites +(133 tests), full and strict acceptance TypeScript checks, and four isolated mutants also pass. +The ledger is 10 Pass / 22 Partial / 32 Pending. This verifies the owned production fixture; +no deployment to the user's regular desktop or full release/device pass is claimed. + +**Fresh-desktop relay pairing refusal (A93, open; 2026-10-04).** The same Pixel identity +paired with a fresh isolated production desktop whose remote access was on, but the saved +pairing had no relay credential. A bounded retry using the exact unchanged production request body +returned HTTP 403 reauth_required. Forgetting the old fixture removed its phone-side relay +token; the new desktop has a different identity. The refusal is measured. Original +same-desktop recovery succeeds after ordinary restart of the complete original owned fixture +profile, preserving its host/device identity: genuine beta-9 re-pairing obtains relay credentials +and the beta-10 saved pairing reconnects. No backend repository was reviewed, phone identity reset +or user credentials copied. This measures one recovery case; the fresh-different-desktop failure +remains open. **iOS follow-up for @eneskirca:** verify prior-token lookup and recovery messaging +after Forget for the same persistent phone identity. No new external field is introduced here. + +**Empty-host loading follow-up (A94, fixed in 3c217cba; delivered in beta 10).** On beta 9, +the otherwise-empty SSH fixture completes its listing and shows the error banner, Over SSH and +New terminal, but still says “Loading sessions…”. The initial empty snapshot's zero fetch +timestamp causes that label. The correction stamps the completed empty answer; five actual +Kotlin regression methods and eight isolated mutants pass. Full Gradle checks and beta-10 +delivery and installed full focused empty-host End/recreate/open/End verification pass. No host contract changed. + +**Installed A91/A94 focused empty-host cycle passes (2026-10-04).** On beta10/code11, +native End of the otherwise-empty fixture's sole owned Home shell removes its row/group and +shows “No sessions on this computer yet.” with no Loading label; Over SSH and the data-not-found +banner remain. New terminal → Home → Create opens a new actual bash pane in Home. Back to +Sessions and exact native End removes that new UUID and restores the same completed empty +screen. All three isolated host sockets are empty. Proof: +`.nodeterm/android-beta-build-9/checklist-20261004/beta10-a94-full-cycle-result.json`, +`beta10-a94-recreated-pane.json` and the matching empty-screen UI receipts. This passes the +focused A91/A94 empty-host flow; item 32 remains Partial because managed and cellular +creation remain open; the relay plain-shell variant passes. Total 10 Pass / 22 Partial / 32 Pending. Final fixture/service cleanup is +not yet claimed. + +**Focused plain-SSH Pixel proof (`A90`, 2026-10-03):** beta 8/code 9 (`b88d1415`) creates +Home, discovered host-project and custom-folder shells, including spaces/apostrophe/dollar in +the custom path. Real cwd/input, 180 numbered history lines, same-pane reopen/pre-attach history, +14 drag-position changes, coast from 65 to 137 and Esc exit pass. Rapid double Create adds only +one shell; a missing folder shows an error, creates no orphan and can be corrected/retried. +Phone shells have no managed-agent environment and a 50000-line history limit. Force-stop/restart +retains all three PIDs/history and SSH rediscovery. Updating to beta 9/code 10 (`4d33a5b5`) also +retains them; reconnect and native input to the same owned pane pass. Native UI End is verified +for custom → project → Home: only Open/End actions, phone-shell confirmation, exact selected +UUID removal, sibling immutable fingerprints/PIDs unchanged, counts 3→2→1→0 and the Phone terminals +group disappearing. All eleven pre-existing desktop session IDs/pane PIDs and nine protected +project/workspace hashes are unchanged. Owned disposable shells, empty fixture folders and remote +UI dumps are removed; the Pixel is left on regular host Sessions. Proof: +`.nodeterm/android-beta-build-8/a90-pixel-check-20261003/final-focused-results.json` and +`beta9-exact-ui-end.json` in that directory. Only **item 32 becomes Partial**: relay canvas/managed +and cellular creation remain unverified; the separate `A91` otherwise-empty-host variant passes on Oct4; the +Windows variant stays conditional SKIP. This Oct3 checkpoint was **7 Pass /21 Partial /36 Pending**; later item 1/36/51 checks raise Pass to ten. +Broader device checks resumed on 2026-10-04; their new outcomes are recorded separately. + +**Beta-9 delivery (2026-10-03):** private `0.1.0-beta.9` / code `10`, clean source +`4d33a5b5366c99479b648086649205350c7752b1`, built in 43 seconds and updated the exact intended +Pixel with the retained signer in 6.46 seconds. Its pulled installed APK matches SHA-256 +`719cfeea1dcf27900dd35692a59004ca07e8261b3f14bd43922f0706b6b4ab54`. +Three owned phone shells created on beta 8 survived the update and were rediscovered on beta 9, +then ended through the verified exact-session cleanup above; none remain. +Independent SDK 36/37 artifact review verifies signatures/16-KB alignment, all 149 unsigned +payloads preserved after signing, four ELF alignments, expected R8/service metadata and source/hash +provenance. Installation preserves `firstInstallTime`, notification grant and app data; the +package is non-debuggable. This does not prove paired relay credentials survive. Private proof: +`.nodeterm/android-beta-build-9/artifact-review.json` and +`.nodeterm/android-beta-build-9/device-install-20261003/receipt.json`; private APK: +`.nodeterm/android-beta-9/nodeterm-android-0.1.0-beta.9.apk`. +The historical source CI [run `37144282865`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37144282865) +failed `DeviceChecklistDocsTest` because the new finding's pending device check lacked a checklist +mapping and unrelated Known gaps bullets shared one paragraph. Debug APK, release APK and CodeQL +jobs passed; private-beta packaging was skipped. This is not a green workflow receipt. The recorded +prior all-green branch was `19da35a2` / run `37140762345`; the next push must pass all its checks. +This revision maps A91 into item 32 and isolates its Known gaps paragraph. Fresh full protocol +and offline app gates must follow the frozen final documentation; the recorded 688/67 local +source baseline predates these final documentation changes. Final exact-head CI proof is retained +separately after the next push. + +**`A91` empty-host stale rows are fixed in `4d33a5b5` and delivered in beta 9 (2026-10-03).** Ending the last phone-owned shell +on an otherwise empty SSH host makes browse return `NothingFoundException`. The native refresh +previously kept the ended shell's cached row. `ListingFailure.snapshot` now replaces the cached +listing with `ProjectsSnapshot.EMPTY` only for that authoritative empty answer; ordinary host or +transport failures retain it, and cancellation propagates. `ConnectionManager` preserves the +route-specific error and connected SSH state, so New terminal remains available. Four new +`ListingFailureTest` methods cover the real parsed phone row, retention, cancellation and native +wiring. The complete real Gradle protocol suite passes **688 tests / 67 suites**, zero +failures/errors/skips, in 52 seconds; offline app `compileKotlin` passes in 6 seconds. Six isolated +Kotlin 2.2/JDK 21 mutants are caught; proof is in `.nodeterm/android-beta-build-9/`. +**Physical otherwise-empty-host variant passes on beta 9 (2026-10-04).** The first Home shell +is created and opened through New terminal. Native End removes its exact row and the Phone +terminals group; SSH remains connected. New terminal remains usable and creates/opens a second +Home shell, then retained for the beta 10 update and subsequently ended in the A91/A94 flow above. No final fixture cleanup is claimed. +Proof: `.nodeterm/android-beta-build-9/checklist-20261004/a91-beta9-focused-results.json`. +The A94 false Loading label is corrected in beta 10: both installed sole-End cycles show +the completed empty label with no Loading, row or group, while SSH/error remain; Home recreation opens an actual bash pane between them. Item 32 stays Partial; item 1 separately raises the current +tally to 10 Pass / 22 Partial / 32 Pending. No RPC/blob/pairing/mirror/SSH-visible file contract changes are made. + +**Historical beta-8 installation receipt (2026-10-03):** the exact intended Pixel 10 Pro / Android 17 was +confirmed before a 28.05-second same-signer `adb install -r` of `0.1.0-beta.8` / code `9`. +The pulled pre-update APK matches the known beta-6/code-7 hash; SDK 37 confirms the retained public +signer before replacement. The pulled post-update APK matches beta 8's +`d373ad5c1790f714cb4464ad4a0a38c5ba9ab68e35103e54cf3aef5ce53081ce`, built from +`b88d141528c1051964da07faf22cc7fa923c4846`. Package metadata confirms code 9/name beta 8, +non-debuggable, with `firstInstallTime` and granted `POST_NOTIFICATIONS` preserved. The app starts +and the existing manual-SSH computer remains in All computers. Opening that host connects over +SSH and lists its real driven projects; the screenshot shows **New terminal**, and own-app UI XML +confirms the FAB is enabled/clickable. No existing pane was touched and no terminal was created +or ended. This establishes host configuration/reconnect/browse survival, not desktop-issued +pairing/relay credential survival or item 32's creation/persistence/End flow. Private proof: +`.nodeterm/android-beta-build-8/device-install-20261003/receipt.json`, package/APK/certificate +checks, `sessions.png` and `ui-sessions-ready.xml`. This makes no TalkBack claim. The +7 Pass / 20 Partial / 37 Pending ledger remains unchanged. + +**`A90` implementation and beta-8 host baseline.** Protocol commit +`bcc92367` and UI/model commit `b88d141528c1051964da07faf22cc7fa923c4846` implement dedicated-socket +creation, atomic metadata for interrupted-request rediscovery, frozen UUID/folder retry, live +fingerprint ownership checks and exact End. Real SSH/tmux regressions cover Home and driven folders, +hostile path quoting, renamed-folder retry, partial creation before option finalization, +reconnect/history, warm-server identity/locale cleanup, stale/foreign ownership and socket isolation; +relay interop refuses reserved phone IDs before any creating RPC. The complete protocol suite +passes **684 tests in 66 suites, zero failures/errors/skips** (48 seconds), and the offline app +`compileKotlin` passes (1 second). Eleven actual Gradle/Kotlin 2.2 protocol behavioral mutations, +twelve helper behavior mutations and nine native wiring mutations are caught (**32 total**). +Private proof is in `.nodeterm/android-beta-build-8/`. These are host checks, not Pixel proof: +at that host-baseline checkpoint, item 32 remained Pending and the tally was 7 Pass / 20 Partial / +37 Pending. The focused physical proof above now makes item 32 Partial. + +**Historical beta-6 Pixel checklist follow-up, requirement-audited 2026-10-03:** seven complete items pass: +**18, 19, 21, 22, 24, 38 and 39**. Twenty items have partial evidence and 37 remain pending; conditional SKIP variants +below do not pass or close their parent item. This evidence used the then-installed minified +`0.1.0-beta.6` / code `7`, source `c4b1f6cf1009f293a658b6331d2ed1ab80aa36c6`. The intended +Pixel 10 Pro runs Android 17 / API 37 and Vanadium WebView `154.0.8037.92.0`. The real Linux +SSH-driven host runs Fedora 44, kernel `7.2.7-200.fc44` x86_64, OpenSSH server `10.2p1-14` and +tmux `3.7c`; the running desktop nodeterm version is not recorded. These tests use owned synthetic +terminal content through the real sshd, not the JVM/SSH fixture or a simulated phone. No private +hostnames, addresses, keys or clipboard contents are recorded here. + +All 17 sending key chips and four application-cursor arrow checks reached the owned pane in +order. A+ changed 56×48 to 52×45; A− restored 56×48, with the connection alive. Rotation retained +the draft and connection: landscape with the IME open was 129×1, and portrait returned to 56×25. +This does not establish more than one row when landscape has usable room; that geometry case +remains open. For each of the three keyboard-chip focus states, the keyboard stayed open and +actual software `a` plus Enter reached the pane rather than the native draft. + +The 90000-character synthetic OSC52 clipboard matched exactly. Both 150000 and 450000 were +refused with the app's size message, with clipboard unchanged and no crash (PNG toasts were +visually checked). This phone result establishes visible refusal; the 450000 pre-bridge cap +remains distinct bounded JVM/JS evidence. Invalid base64, missing separator, read query, +overlong selection and invalid UTF8 were silent and preserved the known synthetic clipboard. +The Copy sheet's taps and long-press range copied three exact Unicode lines (lower box border plus fixture lines A/B); +the system Share chooser preserved selection, and modal interactions emitted zero mouse events. +Sheet links/Open, initial top line, each inert target and non-tmux scrollback variants remain open. +Wrapped HTTPS/HTTP links offered the complete 324/323-character URL from first/middle/last rows, +including `/end`; link Copy and All expansion worked, with Less visible. Its collapse tap was not +separately logged. OSC8 HTTPS was offered while +file/JavaScript links were ignored. Browser Open and URL-offer interaction/scroll variants remain +open, so items 54–56 are partial. + +Backgrounding a real SSH terminal detached its client; foregrounding reattached and retained the +draft. Detaching only the owned tmux client triggered automatic reconnect, again retaining the +draft. Armed Ctrl plus draft `c` delivered exactly Ctrl-C with no Enter. Airplane-mode/outage and +disabled-input states remain untested. A background `am kill` restored Inbox tab and the host +back stack; a force-stop/reopen retained manual SSH registration/authentication with no host-key +prompt. QR pairing, cellular relay/SAS denial/revoke, notifications, custom wheel bindings and FPS remain +open. Driver coordinate expectations, side-Back navigation and Compose class names were corrected +in the QA helpers; they are not product findings. + +**Live production pairing/relay follow-up:** pasting the actual desktop PairingService JSON +succeeds through its encrypted exchange with remote access already enabled. The fixture host is +added alongside the retained manual SSH host. Choosing **Only through the relay** in Settings +opens the isolated desktop's empty Sessions workspace with **"Through the relay · end-to-end +encrypted"**. This is the shipped Pixel app against the real hosted API and +`wss://relay.nodeterm.dev`, so the current `{deviceToken, hostId}` request to `POST /v1/relay/join` +and its accepted response have actual backend proof, beyond interop tests. The desktop runs +production source `58a202be` inside a private bwrap home overlay with real DBus SecretService +encrypted credential storage; no user-profile credentials were copied. Pairing while remote +access is enabled already approves this phone, so no SAS prompt is expected on this path. +Initial Automatic SSH browsing listed the real sshd's files but opened no real node; the route was +changed to relay-only before any node action. A later owned project/plain terminal was seeded +through the actual production preload and Canvas event; the phone attached through the relay, +and its harmless echo input reached that real PTY. This verifies relay terminal attach/input, +not phone New session or the folder picker. Private proof is `relay-terminal.json` and +`relay-input-capture.json` (`hasExpectedEcho: true`). QR/scanner, cellular relay, SAS denial/revoke +and the remaining node/action matrix stay open. Initial phone UI proof is `relay-first-connect.json`. + +**Held-hook approval lifecycle (item 39):** a synthetic application invokes the desktop's shipped +managed Claude hook, scoped to the owned node, private home and authenticated hook endpoint. +Pixel Inbox Approve returns its actual allow JSON in 17.596 seconds; Deny returns deny JSON in +6.571 seconds. A 45-second hold expires with empty output; a later Approve visibly says +"The request timed out on computer. Answer it in session." and opens the owned terminal, +without false success. This passes the held-hook lifecycle with an explicit producer limit: +no live Claude CLI/account or requested Bash execution was involved. Private desktop proof +is in `hook-qa-*.jsonl`. At that stage required checks passed all 658 protocol tests in 63 suites with +zero failures/errors/skips (54 seconds), plus offline app `compileKotlin` (5 seconds). + +**Relay question follow-up (item 41, partial):** on the desktop-mounted owned terminal, one tap +on Green sends option `2` to the actual synthetic application; its real PostToolUse hook resolves +the desktop mirror question. A multi-select card lists read-only options with "Choose several — +answer in session" and Open session; opening the owned terminal and sending `2` plus Enter from +the phone input bar reaches the application and PostToolUse confirms it. For a separate single +question, the exact owned tmux pane is in copy mode before PreToolUse. One tap on Blue sends +option `3`; the application receives it, copy mode is false, and the mirror becomes working with +that question resolved after actual PostToolUse. These use the shipped hooks and synthetic +application, not a live Claude CLI/account. Private `hook-qa-*.jsonl` and phone +`relay-single-before/after`, `relay-multi-refreshed/multi-terminal/multi-input`, and +`relay-copy-question-before/after` proof record the outcomes. Offscreen/released panes, direct SSH, +missing/prefix-collision targets and notification questions are not device verified; regression +tests cover the transport/target guards. Item 41 stays Partial. + +Cleanup forgot only the disposable fixture host and returned the phone to regular Sessions; +only the owned SSH fixture session and isolated desktop/CDP were stopped. Scoped fixture-device +revoke/stop/remote-access-off cleanup left zero fixture devices; it does not pass a full device +revocation or establish a backend revoke result. No new product finding or production change +resulted from these checks. + +**Final beta-6 cellular WireGuard check (user-confirmed, 2026-10-03):** with WireGuard enabled +and Wi-Fi off, the user reports "Connects and scrolls smoothly" in their usual terminal on +beta 6 / code 7, confirming connection and smooth scrolling. This is regular manual SSH over cellular VPN, +not hosted cellular relay. It adds evidence to item 20, which remains Partial; the full scroll, +QR/cellular-relay/worker, outage and other 64-item variants remain open. No runtime fix or phone +command accompanied this check. The latest required protocol task passes in 50 seconds and +offline app `compileKotlin` in 1 second; the preceding 54/5-second QA results remain historical. + +**Requirement audit and after-hike handover:** item 18 is Pass on existing evidence: all 17 +sending chips, four application-cursor arrows, software input, font changes and rotation preserve +the SSH connection (`physical-chips-interior.json`, `keyboard-results.json`, `recovery-results.json`). +The extra route/viewport/Fit matrix previously attached to that row belongs to other items; +item 23 stays Partial. Item 1 needs a desktop-issued pairing and relay credentials to survive a +higher-code update; JSON or QR pairing is acceptable. The user defers remaining Pixel release +checks until after the hike. No new phone work, runtime change or finding produced this tally +correction; full release readiness, hosted cellular relay and live-Claude checks remain unverified. + +**Historical prepared update, unused:** private `0.1.0-beta.7` / code `8` uses source +`b53610deb3843b59fa6a1bed5bdc5f36da0f5146` and the retained signer. The local AGP release built +in 47 seconds; R8 and packaging passed. APK SHA-256: +`5141c6484b422b236a98213731076be621c4d14a55f47c79bfd989fb23609e6a`. +Proof/artifacts are in ignored `.nodeterm/android-beta-build-7/` and `.nodeterm/android-beta-7/`. +It was not installed and was superseded by beta 8 below; +preparing it added no runtime fix or device pass. + +**Historical beta-8 artifact, now superseded by installed beta 9:** private `0.1.0-beta.8` / code `9` contains `A90`, built +from clean source `b88d141528c1051964da07faf22cc7fa923c4846` with the retained signer. The actual +offline AGP release built in 49 seconds; R8 keeps and local packaging passed, including the same +certificate, 16-KB alignment and source/hash provenance. Independent SDK 36/37 tools verify v2/v3 +signatures, one retained signer and 16-KB alignment. All 149 unsigned payloads are preserved, with +three signing entries added; all four ELF PT_LOAD alignments pass. Terminal assets/native libraries +are byte-identical to beta 6; the changed DEX and remapped service entry agree with the feature +source and R8 mapping. Independent proof includes `artifact-review.json`. APK SHA-256: +`d373ad5c1790f714cb4464ad4a0a38c5ba9ab68e35103e54cf3aef5ce53081ce`. +The private artifact is `.nodeterm/android-beta-8/nodeterm-android-0.1.0-beta.8.apk`, with proof in +`.nodeterm/android-beta-build-8/`. Its installation receipt is above. The later beta9→10 update above proves desktop-issued +pairing/relay survival for item 1 without downgrade or uninstall. Debug migration stays +conditional SKIP on the working private installation. +Historical beta-6 proof and its seven Pass / 20 Partial / 37 Pending checkpoint are preserved; +the focused A90 result changes item 32 to Partial. The later paired update passes item 1, +giving the current 10 Pass / 22 Partial / 32 Pending. + +Private JSON/PNG/log proof is in `.nodeterm/android-beta-build-6/checklist-20261002/`, including +`physical-chips-interior.json`, `osc52-results.json`, `keyboard-results.json`, `copy-result.json`, +`link-checks.json`, `recovery-results.json` and `activity-results.json`. The 64 rows below reconcile +that physical evidence with the earlier draft ledger. Recorded CI baseline +`19da35a2` has all five jobs green in +[run `37140762345`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37140762345), including +the A90 branch work. Installed beta 9 is bound to source `4d33a5b5`, whose CI run +`37144282865` failed the documentation checklist mapping. The next documentation push needs +its own checks/green workflow. +CI does not imply full device validation. + +| Item | Result | Evidence or remaining scope | +|---|---|---| +| 1 | Pass | Genuine desktop-issued beta-9 pairing obtains relay credentials after ordinary original-profile recovery; same-signer code10→11 update to beta 10 preserves the pairing, install identity, notification grant and owned SSH pane. Saved pairing relay reconnect/open/input reaches the actual scoped desktop PTY without re-pairing; anchored SSH refusal still warns before AUTO fallback. Debug-key update/migration variants SKIP* to preserve the working private installation. | +| 2 | Partial | Force-stop retains manual SSH registration/authentication; phone reboot, paired SSH/relay pending. | +| 3 | Pending | Uninstall/Clear storage variants SKIP* on working installation; disposable setup required. | +| 4 | Pending | Second-phone transfer/revoke and cloud restore SKIP*; no authorized setup. | +| 5 | Partial | Minified SSH/copy/link/Copy sheet, encrypted paste pairing and hosted relay browse work; worker/exception/debug variants pending. | +| 6 | Pending | Linux QR pairing pending; macOS/Windows variants SKIP*. | +| 7 | Partial | Actual PairingService JSON paste succeeds; camera/deep link and camera-denial fallback pending. | +| 8 | Partial | Remote-access-enabled pairing permits first relay connection without SAS; denial/revoke/SAS variants pending, older desktop SKIP*. | +| 9 | Pending | Pairing timeout, cancellation and expired/used code pending. | +| 10 | Partial | Manual SSH lists 17 projects; paired Automatic grouping/activity/context pending. | +| 11 | Pending | Named-key, LAN refresh and certificate/refusal matrix pending; macOS variant SKIP*. | +| 12 | Partial | Real hosted relay join/browse succeeds on relay-only route; cellular/SAS and token-request interruption pending. | +| 13 | Pending | 15-minute unapproved-host background check pending. | +| 14 | Pending | Actual host/network loss deadline and relay fallback pending. | +| 15 | Pending | Repeated Back-before-connect cancellation pending. | +| 16 | Partial | Relay-only route takes effect and disposable fixture host is forgotten; other routes and two-paired-host matrix pending. | +| 17 | Pending | Late relay adoption and background authorization pending. | +| 18 | Pass | All 17 sending chips + 4 app-mode arrows, software input, A−/A+ and rotation preserve the real SSH connection; existing physical proof audited. | +| 19 | Pass | Rounded borders, accents, CJK and emoji rendered on real SSH. | +| 20 | Partial | Drag/coast/Esc/new-touch stop and small OSC52 work; user confirms smooth beta-6 cellular-WireGuard SSH scrolling, full matrix pending. | +| 21 | Pass | 90000 chars copied exactly; 150000/450000 refused with size toast, no crash. Bridge boundary is JVM evidence. | +| 22 | Pass | Invalid base64/separator/query/selection/UTF8 silent; known clipboard preserved exactly. | +| 23 | Partial | Portrait font/IME resize works; landscape usable-height and larger-client Fit case pending. | +| 24 | Pass | All three keyboard-chip focus states stay open; actual software a+Enter reaches pane, draft unchanged. | +| 25 | Partial | Background/detach recovery preserves draft; Ctrl+c is exact with no Enter. Airplane/disabled states pending. | +| 26 | Pending | Renderer kill/crash matrix pending; root/DevTools trigger variant SKIP*. | +| 27 | Partial | Real SSH background detach/foreground reattach verified; relay sizing/refresh variant pending. | +| 28 | Partial | Owned-client detach auto-reconnect verified; actual desktop-mount/relay variant pending. | +| 29 | Pending | Sleeping-session wake paths pending. | +| 30 | Pending | Desktop reboot/resume/account/permission paths pending. | +| 31 | Pending | Desktop SSH-project relay routing pending. | +| 32 | Partial | Real Pixel beta 8 Home/project/custom plain-SSH creation, cwd/input/history, double-Create/error retry and force-stop/reconnect pass; beta 9 update retains three shells and exact native UI End removes only each selected UUID, preserving siblings, 11 checked desktop sessions/PIDs and 9 protected file hashes. Otherwise-empty private SSH first Home creation/exact last-End/row-and-group removal/connected SSH/second Home creation pass on beta 9 Oct4; second shell survives beta 10 update, then exact sole-End shows completed empty/no Loading/row/group while SSH/error stay; Home recreation opens an actual bash pane and second exact End restores completed empty again. Beta10 actual relay New session→Terminal(shell) creates/opens one canvas node with exact cwd/input; original producer/pane preserved. Managed-account and cellular creation remain pending; Windows variant SKIP*. | +| 33 | Pending | Back/background during new-session launch pending. | +| 34 | Pending | Project/account removal while new-session dialog open pending. | +| 35 | Partial | Actual beta 10 Rename updates the mounted desktop title; session-menu Refresh delivers exactly one own node-refresh IPC with pane identity/page origin retained, but repaint is unverified. Exact native End removes only the adopted node/pane/PID birth, preserving the original producer; Sessions Refresh rediscovers owned rows. Wake and Refresh repaint remain pending. | +| 36 | Pass | Actual beta 10 phone Move to Ungrouped, label removal/re-add and creation/application of a second blue label update persisted state and the mounted repaired production desktop `ec12ea9a` Board, with identical page time origin and no reload. A95's held071 failure is preserved as before evidence; 133 tests/four mutants also pass. | +| 37 | Pending | Board project/tab/scroll persistence pending. | +| 38 | Pass | Background am kill restores Inbox tab and host back stack. | +| 39 | Pass | Actual shipped managed hook returns allow/deny and expiry opens terminal without false success; synthetic producer, no live Claude/Bash execution. | +| 40 | Pending | Subagent approval while parent waits pending. | +| 41 | Partial | Mounted relay single/multi/Open session and copy-mode answer reach synthetic application/PostToolUse; offscreen/released/SSH/target variants pending. | +| 42 | Pending | Unread/read-ack ownership and sweep matrix pending. | +| 43 | Pending | Background notification arrival/deep-link matrix pending. | +| 44 | Partial | Notification grant persists; deny/re-enable pending. Fresh-denial variant SKIP*. | +| 45 | Pending | Live-notification suppression/delivery matrix pending. | +| 46 | Pending | Notification deduplication/dual-host matrix pending. | +| 47 | Pending | Lock-screen privacy/settings matrix pending. | +| 48 | Pending | Usage/context-meter correspondence pending. | +| 49 | Partial | Actual beta 10 system-Back/reopen preserves edited name/valid HTTPS loopback API; invalid HTTP field error and visually reviewed toast appear, prior API remains. Reset/default and original name/API restoration pass; unedited visible values stay unchanged. Internal preference-key absence/no-write rules remain physically uninspected (existing SettingsLeaveTest behavior/source guards cover them). | +| 50 | Partial | Portrait terminal IME fit verified; Pair/Settings/dialog layouts pending. Android 8–14 variant SKIP*. | +| 51 | Pass | Actual beta 10 Pixel Computers/Settings native screens and system bars are visually readable under system dark/light modes; four matching screenshots reviewed. App keeps its fixed dark palette; original night mode yes restored/verified. Tablet/foldable variant SKIP*. | +| 52 | Pending | Relay-assisted actions and remote-access toggles pending. | +| 53 | Pending | Source-control branch/status/diff/stage/commit/push/pull/conflict/hook matrix pending. | +| 54 | Partial | Wrapped full URL/Copy/All expansion verified; Less collapse, browser Open and remaining bar/isolation/link variants pending. | +| 55 | Partial | HTTP(S) OSC8 offered, file/JavaScript ignored; Open pending. Windows/external-mouse variant SKIP*. | +| 56 | Partial | Taps/long-press range copy three exact Unicode lines; Share/modal isolation verified; links/top line/inert targets/non-tmux scrollback pending. | +| 57 | Pending | Notification approval actions pending; Android 8–11 variant SKIP*. | +| 58 | Pending | Notification question actions pending. | +| 59 | Pending | Already-handled/expired/unreachable notification answers and repeated-tap behavior pending. | +| 60 | Pending | Two-computer Inbox/Usage/approval/state matrix pending. | +| 61 | Pending | Dictation/service/permission/edit/offline matrix pending. | +| 62 | Pending | Separate desktop-driven Linux host matrix pending. | +| 63 | Partial | Manual SSH auth/pin/list/input work; headless Server Edition and failure/fish variants pending. | +| 64 | Pending | Separate driven-host read-ack/key-change/Forget matrix pending. | + +*SKIP is limited to the named unavailable variant: no disposable debug/migration or destructive +reinstall profile; no second phone/cloud-restore setup; no macOS/Windows/older desktop; no +root-capable renderer-crash setup; no fresh notification-denial profile; no Android 8–14 or +8–11 comparison device, tablet/foldable or Windows/external-mouse setup. Applicable core checks +remain pending and these variants must be revisited when their prerequisites are available. + +**Historical stable-touch correction (`A89`, 2026-10-02):** beta 5 still stops during continuous +swiping and the user must lift to continue. Its controlled phone history shows no coast after the ADB +swipe command completes. Real xterm reproduces the cause: redraw replaces the touched text +span, so later touchmove/touchend no longer bubble to the handlers. `c4b1f6cf` gives the stable +screen `touch-action: none` and changing rows/descendants `pointer-events: none`. The real-bundle +regression verifies computed CSS hit testing and continued drag/release across an actual redraw. +The real-bundle redraw case delivers 24 notches versus one with a detached span. All 658 protocol +tests in 63 suites pass with zero failures/errors/skips (67 seconds), and offline app +`compileKotlin` passes (8 seconds). Thirty-five JS, eleven native and three CSS mutants were caught. + +Private `0.1.0-beta.6` / code `7` uses source `c4b1f6cf1009f293a658b6331d2ed1ab80aa36c6`. +Actual AGP release built in 43 seconds and passed every R8 keep. Retained-signer packaging verifies +non-debuggable metadata, signature, 16-KB alignment and source/hash provenance; signing preserves +all 149 ZIP payloads and adds only three signature metadata entries. APK SHA-256: +`4947133a6ccf9c2b1e775e76d7c24f564e087cf036e59eac4dca08162a076d3c`. +The intended Pixel received a same-signer update preserving app data; notification permission +is confirmed. SSH reopened with the retained key/pin in the owned 56×48 pane. A +1000-native-pixel/1200-ms swipe produced 23 observed history-position updates, from 0 to 110 over +about 1103 ms; its ADB command completed at 1535 ms. A 1000-pixel/200-ms swipe reached position +110 when its ADB command completed at 540.5 ms, then 250 at 1339 ms (about 799 ms later), with +29 observed updates overall. This verifies continuous drag delivery and post-command coast. +Esc during coast left copy mode (`mode=0`) and remained out for 1.4 seconds. + +New-touch stopping also passes with the keyboard already open and all sampled viewports at +56×25. After a 500-native-pixel/100-ms swipe, the DOWN command completed 152 ms after the swipe +command; the script then waited 1.2 seconds before issuing CANCEL. The last position change was at 503.6 ms, before DOWN completed at +588.8 ms; final position 100 remained unchanged through CANCEL. Earlier tap/DOWN checks opened +the IME and rebased positions; those results were inconclusive, not additional failures. +After normal terminal use, the user confirmed “Both work now” for continuous dragging and coast. +The primary `A86` drag/coast complaint is resolved. The final beta-6 cellular WireGuard SSH check +confirms connection and smooth scrolling; reversal/lifecycle, FPS/custom +bindings and the full 64-item pass remain open. After testing, Esc and Header Back returned to +Sessions and detached the owned client; only the exact owned `nt-term-000android-scroll-20261002-c` session and owned phone UI +XML were removed. Private proof, including `device-summary.json`, is in +`.nodeterm/android-beta-build-6/`, signed artifacts in `.nodeterm/android-beta-6/`. + +**Historical kinetic beta (`A86`, 2026-10-02):** beta 4 moves more lines but the user reports missing +momentum on finger lift. `1ad2e944` adds bounded velocity fling; `53462f96` adds `onScrollStop` +to discard unsent native scrolling on new touch/input/reset/font/lifecycle barriers without +pane input or loss of accepted keys/replies. Automatic xterm reports preserve it. Bytes already +handed to SSH's separate writer/network cannot be recalled. All 658 protocol tests in 63 suites +pass with zero failures/errors/skips (59 seconds), and offline app `compileKotlin` passes (10 seconds). +Thirty-five JS and eleven new native mutations were caught; seven strengthened kinetic tests pass. + +Private `0.1.0-beta.5` / code `6` uses source `1ad2e94455a7adfb85d41212b12d36df39695324`. +The actual AGP release built in 47 seconds and passed every R8 keep. Retained-signer packaging +verified non-debuggable metadata, signature, 16-KB alignment and source/hash provenance. APK +SHA-256: `7cc68d384aeb21ab40800fa7c83f006dfbfefba16dd2e945967c8c5376f17655`. +It updated the intended Pixel in place; installed code-6/non-debuggable metadata and notification +permission are confirmed. Private proof is in `.nodeterm/android-beta-build-5/`, signed artifacts +in `.nodeterm/android-beta-5/`. The actual gesture reaches history 15 at 369 ms, 25 at 391 ms, +and 30 at 412 ms, then stays fixed through 1.4 seconds after the ADB swipe command completes at +561 ms. Command completion is not a measured physical touchend timestamp. The user reports +continued swiping stops; this failure led to `A89`. Beta-5 build/tests do not prove phone coast. + +**Historical report-routing correction (`A86`, `A87`, 2026-10-02):** the user reports that installed +beta 3 still has both lag and too little movement, on Wi-Fi and mobile-data VPN. Its controlled +gain/reversal/Esc results below did not establish satisfactory feel. `3cffb49d` restores one +measured text row per wheel notch and separates automatic xterm focus/mouse/query replies from +user input: reports preserve queued movement; keys, paste and IME input still cancel it. Frame +batching, lossless ordered chunks, stream retirement and SSH TCP_NODELAY remain. Real bundled +xterm interaction regressions pass; 22 JavaScript and nine actor/native-wiring mutations were +caught. All 646 protocol tests in 62 suites passed with zero failures, errors or skips (50 seconds), and the +offline app `compileKotlin` passed (8 seconds). At that checkpoint, `A86` still needed +intended-Pixel/user follow-up and kinetic fling was absent; beta 6 now has drag/coast confirmation. + +**Historical beta-4 delivery:** `0.1.0-beta.4` / code `5` built locally from +`3cffb49d8cf64932260e914b42b3883331d0352d` with the retained signer. +The code-5 local AGP release built in 48 seconds and passed every R8 keep. Retained-signer +packaging verified non-debuggable APK metadata (minSdk 26, targetSdk 35), signature, alignment +and source/hash provenance. APK SHA-256: +`c64d6a8dea9621265f23a679104149e511ecd243fbdfa53ced401f4cb4f0f6b5`. +The intended Pixel updated in place, preserving manual SSH configuration/key/pin and granted +notifications; the installed app remains non-debuggable. Its owned 56×48 terminal now reaches +positions 25, 45, 70, 100, 130 on the same five 1000-native-pixel/350-ms swipes, versus beta 3's +5, 10, 15, 20, 25. Each gesture produces 4–6 observed host-position changes, reversal moves +130 to 110, and the actual Esc chip leaves copy mode. Private inputs/proof are in +`.nodeterm/android-beta-build-4/` and the APK/checksum/metadata in `.nodeterm/android-beta-4/`. +These verify delivered movement/order/cancellation, not terminal FPS or satisfactory user feel. +The user subsequently confirmed more movement but reported missing momentum; the active `A86` +kinetic-scroll work above addresses that remaining gap. + +**Phone trace limits:** the controlled beta-3 trace records one bridge invocation per gesture and +roughly one content presentation per gesture. Invocation begins 84–90 ms after touchstart; first +presentations occur 170–196 ms after touchstart, as temporal correlation without an SSH/action flow. +Presented Chromium scroll events measure roughly 36–56 ms; the >1-second aggregate mostly counts +no-paint termination. Sparse FrameTimeline data and non-damaging scroll-jank events establish no +terminal FPS or renderer cause. There are no named JavaScript/native methods or scheduler/V8 +measurements, and the parser reports unknown newer extension fields. The trace supports comparing +delivered updates after the fix, not a claim that WebView spends a second painting. + +The matching beta-4 trace records 42 bridge invocations versus 12, 26 content commits versus 12, +and 25 distinct presentations versus 11 over twelve gestures. First invocations occur at 25–36 ms +versus 84–90 ms; this supports more frequent delivered updates, without establishing terminal FPS +or end-to-end SSH latency. Both traces and queries are retained in the private beta-4 evidence. + +**Packaging verifier (`A88`):** the earlier range-label fix `fed68fb3` passed 32 local tests, but +CI run `37058184031` again failed private packaging (five of 32 tests); protocol/debug/release/CodeQL +passed. Official SDK 37 reproduces verification exit 0, one signer and v2 true with `V3.0 Signer:` +certificate labels. `f5fd3821` accepts exact V2/V3.0 single labels and V3.0/V3.1 ranges, +requires one pinned certificate and verified v2, and rejects duplicate/unknown/hybrid identities. +Fixtures use the explicitly installed SDK 36/platform 35 unless a reproduction override is set; +only disposable-fixture verifier output is included in failure diagnostics. All 39 Python tests +pass against each real SDK 36/37, and ten new parser/fixture-selection mutants are caught. +Follow-up [run `37061593216`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37061593216) +at `c37798b6495b4b68df379d0ae80887c23104b66d` completed with all five jobs green, confirming +observed CI repair. Each subsequent push still requires its own green workflow. +Every requested push owes green Android workflow verification; `A68` remains deferred and no PR +is requested. + +**Historical beta-3 scroll update (`A86`):** after checkpoint `cf0487a3`, the user reports poor responsiveness on +both Wi-Fi and mobile-data VPN. Controlled SSH/tmux measurements found amplified wheel steps, +fast-swipe clipping and some TCP_NODELAY-sensitive latency; a bounded desktop DOM replay showed +no backlog at that load. The implemented mitigations match drag distance to stock tmux's five-row +wheel steps, batch by frame without losing distance, serialize scroll/input, cancel unsent scroll +before input and enable SSH TCP_NODELAY. No external host/payload contract or phone setting changed. +All 638 protocol tests in 61 suites passed with zero failures, errors or skips (51 seconds), and +the final offline app `compileKotlin` passed (7 seconds); 30 mutations were caught. Private +`0.1.0-beta.3` / code `4` from `40c4ee49592e2f92fc7e6e9b548ba88a33b1e2d3` built locally in +49 seconds, passed every R8 keep and retained-signer packaging, and updated the intended Pixel in +place. Signature, alignment and source/hash provenance passed; all 149 ZIP payloads were unchanged +by signing. APK SHA-256: `dda44df7ebb541afccd18b428634d66246349ccb62fd202917b77a333fbadba3`. +Manual SSH registration/key/pin and notification permission survived. The app reopens the intended +Linux host over SSH and its controlled test terminal fills 56×48. Five identical 1000-native-pixel, +350-ms downward swipes reached positions 25, 40, 55, 70, 85 on beta 2 versus 5, 10, 15, 20, 25 on +beta 3. Reversal moved 25 to 20 and the actual Esc chip left copy mode. The phone returned to +Sessions and refreshed; only the owned test session was removed, with user panes untouched. +Private proof/screenshot/installed-package checks are in `.nodeterm/android-beta-build-3/`, +including `device-checks.json`. These establish drag gain/order/input cancellation, not FPS +or perceived smoothness. The tiny isolated UI-frame samples are not a WebView renderer trace. +The user subsequently reports that beta 3 still lags and moves too little. Those historical +checks verify gain/order/cancellation, not satisfactory feel; the current correction is above. +At that checkpoint, custom wheel bindings and kinetic fling remained open. The [finding](android-audit-2026-09.md#a86) records the evidence and limits. Push is +authorized, each requested push requires green Android workflow verification, no PR is requested +and `A68` stays deferred. + +**Installed beta history (2026-10-02):** restored host access allowed reconciling remote tip +`82940e17` in `990f90c6` and building the actual app locally. The first AGP 8.9.1 release exposed +R8 Kotlin-metadata warnings (`A83`). The corrected source +`fa71cb08072f399f24a81bfb361ea852a0275f3b` uses AGP 8.10.1 with Kotlin 2.2.0, wrapper Gradle 8.14.3 +and JDK 21. Its real release and final offline `:app:assembleRelease` (15 seconds) passed, the +metadata warnings are gone, and `check-r8-output.sh` passed every runtime keep. + +`0.1.0-beta.1`, version code `2`, was privately signed with the retained signer, verified as +non-debuggable with the expected certificate and local APK/R8/source/hash provenance, and installed +on an MI8 running Android 15 / API 35. The first cold start succeeded and reached the notification +permission dialog. After granting `POST_NOTIFICATIONS`, the app shows its empty Computers screen, +Pair button and Settings, with no crash markers. Installed metadata confirms version code `2`, +minSdk `26` and targetSdk `35`; `run-as` is refused because the app is non-debuggable. The phone's +WebView is `com.android.webview` `144.0.7559.76`. Inputs/logs are in the original workspace's ignored +`.nodeterm/android-beta-build-1/`; the APK/checksum/metadata are in `.nodeterm/android-beta-1/`. +APK SHA-256: `39afa15f15219536e3de1a462f2e5018515847a43684093eef18d95faf5e7fb4`. +**Wrong test device:** the user identified the MI8 as the wrong phone. The newly installed app and +its test UI dump were removed. The phone's newly authorized SSH public key was removed, restoring +the host's `authorized_keys` byte-for-byte to its pre-change state. The MI8 had no paired host and +no SSH connection was attempted. The device evidence above remains first-launch evidence for that +test device only. The subsequent intended-phone results are below. The user's target is this +Linux host over their WireGuard VPN; full validation remains open under `A50`. + +**Intended phone, initial beta:** the user supplied the intended phone's ADB endpoint. The Pixel +10 Pro runs Android 17 / API 37 with Vanadium WebView `154.0.8037.92.0`. Private beta +`0.1.0-beta.1` (code `2`) installed, the user granted notifications through Android's normal dialog, +and `run-as` was refused on the non-debuggable build. Its SSH public key was authorized while +preserving existing authorized entries. Through Add SSH server, the app connected to this Linux host; +the pinned Ed25519 fingerprint matches the host's public key. It lists 17 actual projects, and a +harmless `echo` sentinel entered from the phone executed in a controlled temporary tmux window. +This is manual direct-SSH registration/authentication, not verification of QR/code or relay pairing. + +The initial terminal still advertises only 52/56 columns by one row despite a large visible +viewport, including after changing the font (`A85`). Chromium treats a WebView's `WRAP_CONTENT` +height as zero CSS layout height, despite its large native bounds. The local fix supplies explicit +`MATCH_PARENT` layout parameters before loading the terminal page (`febe022a`); the real Gradle +`TerminalWebViewLayoutTest` wiring guard passes, and two layout-policy mutations were caught in a +temporary source mirror. The actual `0.1.0-beta.2` (code `3`) from +`febe022ad2fc373f27ac11d9ad5f130f36f027a5` passed its offline AGP release build in 45 seconds and was +signed with the retained certificate; signature, alignment and same-build provenance were verified. +Inputs/logs are in `.nodeterm/android-beta-build-2/`; finished APK/checksum/metadata are in +`.nodeterm/android-beta-2/`. APK SHA-256: +`a022a399e23c81607a4a3664862b964ad781f0a589ab77dda902b4c2dc597eca`. +An independent artifact review verified v2/v3 signatures with the same retained certificate, +all APK/provenance hashes, 16 KiB ZIP alignment and 16 KiB load-segment alignment for all four +native libraries. + +Updating the Pixel with `adb install -r` succeeded and preserved its host configuration, phone SSH +key, host-key pin and notification grant. The app reopened the intended Linux host over SSH. The +controlled terminal now reports 52×45 instead of 52×1 and fills its viewport. A downward swipe +entered tmux copy mode at position 82; a screenshot visibly showed the pre-attach ready/sentinel +and marker rows 001–039, from 120 rows printed before the update/attach. The layout fix restores +this pre-attach tmux history without any production SSH-scroll change. A− restored font size 13 +and a 56×48 terminal; showing the soft keyboard resized it to 56×25 with native bounds +`[0,396][1280,1462]`, and hiding the keyboard restored 56×48. The Esc chip left tmux copy mode +(`pane_in_mode=0`). Sending a second unique `echo` marker through the updated beta's draft executed +and displayed the entire output line. The app returned to Sessions; only the owned temporary test +window was removed, with the previous window restored and its original Python process still alive. +The intended phone's app, SSH key and configuration remain. Device checks and controlled screenshots +are retained in `.nodeterm/android-beta-build-2/`, including `device-checks.json`. The full phone +pass remains pending; the subsequent user-reported mobile connection is below. + +**User-reported mobile check:** with WireGuard enabled and Wi-Fi off, the user confirmed that the +intended Linux host's terminal opens over mobile data. This is separate from the ADB-assisted LAN +checks above. Mobile reconnect, approvals/questions, background behavior and the full 64-item +checklist remain open. + +History regressions in `d6619bf6` pass 47 focused real Gradle SSH/terminal/link tests with zero skips. Both JavaScript swipe-direction/disabled-scroll mutations, the real-SSH wheel-direction mutation and the two native layout-policy mutations were caught; production sources were restored. **Beta-2 verification:** all 609 protocol tests passed in 59 suites with zero failures, errors or skips (52 seconds); the offline app `compileKotlin` passed (7 seconds). + +The full desktop type-check and 679 desktop tests passed, with three platform skips. The offline +Gradle app type-check passed, and 23 Python beta-tool tests passed with real SDK APK/signature +fixtures. Eight bounded compatibility/CI guard tests and two toolchain mutations also passed. +The real Gradle protocol run executed 605 tests: 603 passed, two real-SSH tests failed, none skipped. +The two initial SSH failures were test-harness isolation faults (`A84`): a Fedora login-shell +command-not-found handler delays the literal `-R` command, and an earlier no-exit-status test leaves +Escape in the shared pane's Readline state, corrupting the next `echo` into `cho`. The test-only fix +in `1d6b04cc` isolates a fresh non-login pane and initialization environment per test. That earlier +full protocol rerun passed all 606 tests with zero failures, errors or skips; both harness mutations were caught, +and the fixed test source was restored before that rerun. The user authorized this local build +instead of requiring CI for the first beta; each newly requested push still requires green Android +workflow verification. + +Device results remain partial overall, with complete passes for items 18, 19, 21, 22, 24, 38 and 39 in +the latest record above. Items 1/5/10/63 retain install/minified/manual-SSH evidence, and current +key/input/font/rotation survival completes item 18; viewport/Fit, copy/link, mounted-relay +question/copy-mode answers and SSH lifecycle results cover partial items. The private +update preserved manual SSH registration and its identities; pre-attach tmux history is visible. +Paste pairing, live hosted relay browsing/terminal input and the item-39 held-hook lifecycle are verified. +QR pairing, cellular relay, +actual network-outage/answer behavior and the full +64-item pass remain open. The current complete +protocol run after the stable-touch correction passed all 658 tests, as recorded above. Use the +[private-beta procedure](../android/README.md#private-beta), which accepts same-build local +APK/R8/source/version/hash provenance with `buildOrigin: "local"`. + +**Continuation checks (2026-10-02, cached branch base `6afd8f53`):** the new `A78`–`A80` fixes are +local and have not run in CI. Six targeted desktop unit suites pass (170 tests), and eleven +production mutations fail the relevant regressions. These run with a cached Vitest 4.1.9 runtime; +the lockfile's runtime is 4.1.11. Two additional relay verb suites could not load because Electron +is missing. The new SSH shell tests compile and execute +with a cached Kotlin 2.3.20 compiler, minimal model dependencies and temporary test assertion stubs: +three shell tests pass, covering both sockets, copy mode, literal answers/Enter/Escape and errors; +four shell mutations fail. An isolated adapter containing the actual SSH `sendKeys` method refuses +null/nonzero exit status; restoring null-as-success fails that check. This is narrower than compiling +the full SSH transport or running JUnit/MINA. + +In the earlier restricted sandbox, the required full protocol tests and app type-check could not start: system Gradle is absent, and +a recovered Gradle 9.5.1 fails initializing its socket-based lock service in this sandbox. Real +tmux/SSH checks and adb are also blocked by socket permissions. `npm run typecheck` is blocked by +missing desktop dependencies, and `npm ci --offline --ignore-scripts` cannot complete from the cache. +That command deletes `node_modules` and leaves node-pty unpatched/unbuilt; a desktop checkout +recovers with `npm install` or `npm run rebuild` once dependencies can be installed. +GitHub DNS was unavailable in that sandbox, so fetching, downloading an APK, pushing and checking +Android CI were not possible at that stage. **No device checklist item had been run at that stage.** +The checks below describe earlier work; current local checks and partial phone results are above. + +No relay verb or payload changes in this continuation. The desktop fix serves Android and iOS; +@eneskirca should check the iOS direct-SSH answer path for the same copy-mode and exit-status hazards. + +Private beta preparation adds bounded relay HTTP requests (`A81`) and owned read-ack consumption +(`A82`). `RelayApiTest` passed all six methods with real cached OkHttp, Okio, serialization and +coroutines, using in-memory sockets for stalled headers/body, trickling responses and cancellation; +the deadline also bounds dispatcher queueing and preserves a caller's shorter cancellation. +Three initial and three follow-up mutation checks were caught. This is a direct compiler run +(Kotlin 2.3.20, language 2.2), not the +required Gradle suite or a live backend test. The ack changes retain foreign files before reading or +deleting them and aggregate ownership across every project on an SSH host; Android's producer format +stays the same, with new producer/consumer interop coverage. iOS should preserve the same ack format +and confirm multiple-desktop behavior with @eneskirca. + +For `A82`, five desktop suites pass 225 tests (ack sweep, mirror, remote ack/project teardown), and +the actual Android producer plus real desktop consumers pass two ack interop methods and the new +bundle path-coverage check under that cached compiler. Eight mutations were caught; a focused +strict TypeScript check of the ack core/new fixture also passes. The separate fixture guard's +`tsc --listFilesOnly` child fails with `EPERM` here, so its full check remains unverified. + +The [private beta procedure](../android/README.md#private-beta) keeps the signing key and finished +APK local. Pushes to the exact takeover branch prepare versioned unsigned beta inputs without a +PR; manual CI beta inputs provide the later optional path once the workflow is on the default branch. +A verified local AGP build is now also authorized; its same-build unsigned APK, R8 reports and +provenance use the same required fields, with `buildOrigin: "local"`. The CI path selects those inputs; +the beta checks require protocol/release success, desktop type-check and delivery/ack tests, and +real-tool packaging regressions. Local packaging verifies the expected private signer, release +manifest, R8 keeps, alignment and checksum. **The actual corrected release is signed and installed +on the intended Pixel; the code-3 update preserves identities, resizes with font/keyboard changes +and exposes pre-attach tmux history. The remaining full phone validation is pending.** Packaging fixture APKs prove the tool's gates only; the +actual APK and partial phone results are recorded above. `A50` stays partly open until the full +phone pass. + +The user confirmed a first private beta; its retained RSA-3072 PKCS12 signer is now prepared in +the original workspace's ignored `.nodeterm/android-beta-signing/`, outside the temporary source +checkout. The directory is `0700` and every file `0600`; passwords stay in separate local files. +The private-key entry, certificate fingerprint and distinction from the public debug certificate +are verified. Source exports contain none of these private files. Preserve a private backup for +future APK updates. The wrong MI8 installation was removed; the same first signed APK is now on +the intended Pixel and updated in place to the code-3 sizing fix. The full phone pass remains open; +each requested push requires green Android workflow verification. + +Beta tooling checks pass 22 real SDK packaging fixture tests plus one selected-version environment +test (12 cases), with 13 packaging mutations and one version-validation bypass caught. Seven CI +configuration tests pass under the cached compiler; six workflow mutations are caught. Nine existing +device/contributor documentation tests pass under that runner. The extracted Gradle version +expressions also pass nine boundary/default cases; this does not run Gradle configuration. The full +protocol/app Gradle commands were retried in the restricted sandbox and stopped before project tasks +at the lock service; desktop type-check stopped at missing `electron-vite/node` types. Restored-host +results supersede those limits above. No continuation CI result is available. + +`android/protocol` has no Android dependency and is tested on a JVM (`./gradlew -p protocol test`): + +- **Crypto** — byte-for-byte against vectors generated by the desktop's own `tweetnacl` and + `node:crypto` HKDF (`scripts/gen-crypto-vectors.cjs`): key derivation, `box.before`, secretbox + across block boundaries, tamper rejection, session key, SAS, relay host id. +- **Relay** — the Kotlin client against the desktop's real `connectHostSession`/`connectRelay` + through a local broker: handshake, SAS agreement, approval wait, `projects.list`, attach with + snapshot paint (including a >256 KB snapshot whose chunk boundary splits a code point), input, + resize/`OP.Resized`, exit codes, scroll, detach/destroy, node actions, board verbs (`null` = + Ungrouped), `approvals.answer`, `inbox.ack`, registration, and the `git.*` verbs. What is real is + the verb routing and its validation; the pty, board, inbox and node-action bridges behind the + verbs are fakes that record what was asked. The git bridge is not: it is the desktop's own + `GitService` (`src/core/git-service.ts`, what `hostBridge.git` hands both phone hosts) behind the + production jail, over a repository the fixture makes in the project's folder (`A29`). The + `projects.list` blob is the desktop's own: `buildProjectsListBlob` + (`src/core/projects-list-blob.ts`, which the desktop's `listProjectsOutput` calls too) over a real + `WorkspaceStore` (it writes the v3 index and the project file, then assembles them) and an + `agent-status.json` written by the real mirror from Claude hook payloads (audit `A64`). The `lan` + field beside it is the desktop's own `createHostLanReporter` over the test's interfaces and + host-key dir (`A74-refresh`): the phone parses the address and keys, and a record that pins a key + the computer no longer has is refused by a real MINA server before the refresh and accepted (and + pinned) after it, once the report confirms the key that server was refused; a pin the computer + still reports but its sshd no longer serves gives way the same way; a desktop that sends no field + changes nothing. +- **Relay security** — scripted-host tests for: no re-key after ready, reflected boxes (own role) + dropped, replayed/reordered sequence numbers dropped, boxes under a foreign key dropped. +- **Pairing** — against the desktop's real `createPairingService` with HOME in a temp dir: the + E2EE-sealed exchange, the key landing in `authorized_keys` under `nodeterm-ios-`, the + relay leg and its `/v1/relay/device` body, a refused wrong token, the relay key pinned at the scan + and, without a relay leg, recorded and approved on the first relay handshake (`A07-late`: the + fixture asks the service what the standing host asks, for the phone's key and a stranger's, before + and after revoking), the SSH host keys a sealed answer names (`A49-anchor`: read from a host-key + dir the fixture is pointed at, never the machine's `/etc`; in the form sshj reports; none in a + plaintext answer; a first connect to a real MINA server whose key they name pins it, and one whose + key they do not name is refused), and the size of the desktop's largest answer (1,803 bytes with + the 16 host keys it sends at most, counted through a proxy). Scripted local servers pin the client's + bounds on an answer from whatever `host:pairPort` a code names: a declared length is refused + above 64 KiB or below zero before anything is allocated, a body with no length stops at 64 KiB, + and the whole exchange ends at a 45 s deadline (or when the caller is cancelled) by closing the + socket, so a server that trickles bytes cannot hold the pairing screen. +- **SSH** — against Apache MINA sshd running every command through a shell, with real tmux on a + private `TMUX_TMPDIR`: v3 index resolution, attach with keystrokes both ways, cold-start + detection, literal `send-keys` (a leading `-` is text), answer files, read-acks, host-key pinning. + Both sockets (`A27`): a computer with no nodeterm of its own that another desktop drives (its + `nodeterm-rmt` sessions, project file and slices, a stale slice dropped, attach / keys / pane read / + kill landing on `nodeterm-rmt`, nothing created for a session that is not running, also on a + connection that has not listed yet), one with both sockets in use (a name on both is the host's + own; the paired desktop's own SSH project stays relay-routed), a Server Edition data dir, and a + computer where nothing is found, also when only stale slices remain. A node no listing names is + not offered the relay, and the first node-scoped call on a fresh connection settles which nodes are + whose. The SSH server's commands get none of the developer's `NODETERM_*` variables. A computer + added by its SSH address (`A27`, `ManualHostTest`, its own MINA server whose authenticator reads + `~/.ssh/authorized_keys`): refused before the key line is installed (no pin, no record), the + install command run under `/bin/sh`, then accepted and pinned to exactly the server's host key; a + later connect verifies that pin and another server at the address is refused; a server that drops + the connection during login is reported as unreachable, not as a refused key; the form's checks, + the record's JSON (and what an older build reads of it, and what this build reads back after an + older build saved it without the flag), and the app's wiring, pinned in its source. + No desktop code runs on this leg: the test writes the files the desktop would have (the v3 + `workspace.json` index and project files, `agent-status.json`, the status slices, the held request + in `~/.nodeterm/pending`), and checks what the phone writes against file names copied from + `pending-approvals.ts` and `ack-sweep.ts`. `SshScriptsTest` runs the browse under `/bin/sh` with a + stand-in tmux for the walk up to project files, the slice names and the Server Edition's data dir; + `HostBrowseTest` pins the assembly rules and reads the desktop's heartbeat, slice file name, server + data dir and socket names from its sources. + A command without a pty runs as `/bin/sh -c `. One that asks for a pty runs under `script(1)` + in place of sshd's pty: util-linux's `script -qfec /dev/null` on Linux (which runs `` + through `$SHELL`), BSD's `script -q /dev/null /bin/sh -c ` on macOS. They are told apart by + `script --version` and a probe (`SshTestHost.kt`); with neither, or without tmux, the class skips. + The temp root is short and resolved (`/tmp` first): under macOS's `/private/var/folders/…` the + socket path passed the 103-character `sun_path` limit (audit A62). Only the Linux leg has run. + One tmux fact measured along the way: an exact-match **pane** target is `=name:`, not `=name` + (tmux 3.4 answers "can't find pane"). + +The **app** module is built by CI (`.github/workflows/android.yml`) against the runner's Android +SDK. The first run, [36109984730](https://github.com/CPlusPlus17/nodeterm/actions/runs/36109984730) +on `2f58918`, built the debug APK successfully. CI also builds an unsigned release APK, the only build +type R8 minifies (audit `A37`). A missing `-dontwarn` then fails CI instead of the first release, +because R8 reports the missing class. `tools/check-r8-output.sh` then checks that these existing +keeps for code reached by name matched: the WebView bridge's methods, the WorkManager worker's constructor, +BouncyCastle's provider tables, and one exception class's name (error text can fall back to it). A +missing `-keep` is otherwise not detected: R8 renames or drops code it cannot see used and reports +nothing, so a new reflection or name-dependent target (a new `Class.forName`, a class a library loads +from a string) builds green without its keep. It needs its own keep and a line in +`tools/check-r8-output.sh`. `R8RulesTest` re-derives the classes Android lacks from the jars the +protocol module ships to the app, and requires a keep for every WorkManager worker in the app sources. +The debug APK stays unminified. CI attaches unsigned release inputs; the private-beta path signs +them locally. The app has no instrumented tests. The actual private minified APK is installed on +the intended Pixel and has listed real projects and delivered basic terminal input over SSH. +The corrected code-3 update resizes for font/keyboard changes and exposes pre-attach tmux history; +code 4 verifies reduced drag gain, reversal and Esc cancellation. Remaining +relay and device behavior are unverified; the +[device checklist](#device-checklist) below is what the full device pass has to run. An audit of the code found release blockers; the fixed ones are +marked in its index, and the rest are open: [`android-audit-2026-09.md`](android-audit-2026-09.md). +The plan and the decisions still open are in [`android-handover.md`](android-handover.md). + +Interop coverage (audit `A64`) now includes actual producers on both routes. Relay `projects.list` +uses `buildProjectsListBlob` over the real `WorkspaceStore` and mirror writer; session-list and +settings-provider inputs remain fixture data. Server-profile tests read actual Server config/platform, +workspace and mirror publications over private SSH, including selected-profile isolation and Board +persistence. This component fixture does not boot the full Server or install hooks/account probes. +`SshActionsInteropTest` and `ManagedSessionInteropTest` exercise actual selected-profile services and +Kotlin writers; the managed fixture keeps its native process/CLI recorder boundary explicit. +`AckSweepInteropTest` drives the actual Android `.seen` writer and desktop local/remote consumers. +Two additional `SshTransportTest` methods now read the actual account-level relay-advertisement +writer/remover through the Kotlin SSH parser, checking every field, replacement, removal and profile +isolation. Their producer adapts the OS home only during initialization into a private scratch +folder; it does not run a standing relay host, mint tokens or prove adoption, SAS approval or revoke. +Legacy/malformed browse, held-file and driven-status-slice fixtures remain hand-maintained. The +`A02` directory spelling and `SshScriptsTest` prelude checks remain useful; parser unit tests +(`ModelTest`, `UsagePaceTest`) deliberately use malformed and edge-case blobs. + +Since the audit, the SSH tests also cover: resize/keystrokes/close from a thread that must not do +network I/O (a JVM stand-in for Android's StrictMode, `A01`), a transport that breaks mid-write +(`A01`), and non-ASCII through an attach whose host sets no locale (`A03`). The relay interop tests +cover an approval answered after the hook's hold ended (`A06`), and the desktop tests run the +generated SSH answer command under a real `/bin/sh`. `QuickActionsTest` pins which node state each +Inbox quick answer needs (`A38`): a ticketed approval is judged by its card, so it is answered while +the node still shows WAITING for a held question; keys are typed only for a ticketless approval on a +BLOCKED node or a question on a WAITING one, and only while the fresh listing still lists the card +unresolved. A card the computer's feed has dropped (it keeps an event 6 hours, and trims its feed to +50 events, keeping only each node's newest unresolved ask) opens the session instead: a key carries no +identity of the prompt it answers, and the node may be blocked on a newer one by then (the review of +`A25`). `QuestionChoicesTest` pins what a question card shows (`A57`): a single-select question keeps its answer buttons, while a multi-select one lists its options +numbered and read-only under "Choose several — answer in the session." beside "Open session", and +`QuickActions` never types into it. The card's drawing is pinned in the source and only type-checked. +`UsagePaceTest` pins the Usages pace line (`A58`): no line +without a reset time, 300/10080-minute defaults for the session/weekly kinds (Claude reports no +window length), none for an unknown kind, and injected clocks; the elapsed share is taken at the +account's `updatedAt` (when the percentage was measured), so a stale snapshot never drifts toward +"slower". `Osc52Test` pins OSC 52 copy +(`A53`): parsed like the desktop's `parseOsc52` (the `;` is required, a `?` read query is refused, +base64 and UTF-8 are decoded strictly), but capped at 100,000 characters, because the clipboard +write is a binder call whose buffer (about 1 MB, shared) the desktop's 1,000,000-character base64 +cap does not respect. `TerminalJsOsc52Test` runs the app's real `terminal.js` in node against stub +xterm/bridge objects and checks that it applies that cap before a copy crosses the WebView bridge. +The app also catches a failing `setPrimaryClip` and says so in a toast; that part is not tested. + +Links and copying in the terminal (`A32`) had no way in: tmux runs `mouse on`, so xterm's own +selection never runs, its link handling stands aside, and tmux's copy-mode is out of easy reach on a +touch screen. `TerminalJsLinksTest` runs the real `terminal.js` in node against a stub xterm buffer. +A URL is matched across the rows it wraps over: xterm's soft wraps, and the full-width rows a tmux +repaint or an agent's fullscreen TUI paints with no wrap flag (the desktop's `file-links.ts` URL +matcher, ported; `@xterm/addon-web-links` joins only soft wraps, so a long OAuth URL opened its first +row's fragment). A tap on a link, an OSC 8 link included (its label hides the URL), hands the bridge +the URL as the URL parser writes it and cancels the touchend, so the click the tap makes never +reaches tmux or the app in the pane; a tap anywhere else, a swipe or two fingers are left alone. OSC 8 +links go through `options.linkHandler`, never xterm's `confirm()` (the WebView has no WebChromeClient +to show one), and only http(s) crosses from any way in. The app then names the host (`Open ?`) +and shows the URL, two lines of it until All shows the whole (a long one scrolls inside the bar), and +opens it with a browsable `ACTION_VIEW` only on Open; `ExternalLink` +(`TerminalCopyTest`) checks the URL again: http(s), a host, printable ASCII, and the host named is the +one after any user info. The key row's Copy chip opens a sheet of what the buffer holds (its last 500 +rows, which under tmux is the visible screen; soft wraps joined) and the links in it. `TerminalCopyTest` +covers how `TerminalCopy` reads that snapshot, the selection (a tap toggles a line, a long-press +selects the range from the last line tapped), and the 100,000-character cap the OSC 52 copy keeps, +for Copy and Share alike. `TerminalLinksWiringTest` pins the Android half in the source, including +that every bar, card and sheet drawn over the terminal keeps a touch on it from reaching what it +covers (`blockTouchesBelow`): Compose hands a touch to the sibling below wherever the one on top has +no pointer-input node, and a background or a `Text` has none, so a tap on the bar's URL or the +sheet's title reached the WebView (a click or a scroll in the pane) or the input bar (the keyboard). +The blocker consumes nothing, since a consumed move would cancel the overlay's own taps and scrolls. +The offer, the sheet, the intents, whether a touch on them stays there, and whether a tap on a phone +produces the events the page expects are a device check. The matching shares the desktop's limits: +text that exactly fills a row can be joined with the next, a URL inside a box a TUI draws with `│` at +both edges is not joined, and each CJK character before a URL on its row shifts where a tap lands by +a column. One difference: below a run +of more than 32 such full-width rows, the desktop's join could leave out the row asked about (a +missed link there); the port's always includes it, which also keeps the Copy sheet's scan through +such a run from standing still (a test runs one). + +An SSH test pins that a server which completes the key exchange and then refuses the phone's key +(or user) leaves the host-key pin empty (`A49`), and that a first connect to a server whose key the +pairing did not name is refused before the phone's key is offered, while one whose key it named +connects and pins exactly that key (`A49-anchor`). `HostCertificatePinTest` runs a MINA sshd that +presents a host certificate: the phone matches the pairing and pins by the key it certifies (not the +certificate's own fingerprint, which the desktop never reports), a reissued certificate for the same +key connects with that pin, and a pin an older build took from the certificate itself still connects +and is rewritten as the key (review of `A74-refresh`). `HostKeyAnchorsTest` checks that sshj +fingerprints GitHub's published host keys exactly as OpenSSH prints them +(`src/main/ssh-host-keys.test.ts` checks the desktop's reader against the same pair), the parsing +and the record, and that a plaintext answer's keys are ignored. +`SshFallbackTest` pins what follows a failed SSH leg (`A74`): a changed key goes on to the relay in +Auto with a warning, stops on the SSH-only route, and its text names "Only through the relay" rather +than only re-pairing; a key the computer never reported is sent to the relay without the promise +that pairing again trusts it (review of `A49-anchor`). `HostKeyAnchorsTest` also pins, in the app's +source, that its pin (`ConnectionManager.pinFor`) hands the verifier the anchors its record keeps and +that the SSH dial uses that pin: the interface's default (no anchors) would compile without it. +`LanRefreshTest` pins the refresh (`A74-refresh`): only a relay listing counts, +only a dialable IPv4 is taken, the pin gives way only to a refused key the computer reports (also when +the pin is still among them) and never merely for missing from the report, no keys leave the pin +alone, and a computer added by its SSH address or paired relay-only is untouched; `SshFallbackTest` +pins that the relay leg is handed the refused key. +The app's use of them (the relay dial behind `RelayApprovalGate`, the warning on the host screen, +the refresh after each primary relay listing, whose order in the source is pinned) is only +type-checked. + +`TerminalHandoffTest` pins the terminal screen's attach hand-off (`A40`). A stream that arrives +after the screen left (back, or the app going to the background), or from an attach a newer one +replaced, is detached instead of shown. A session the phone starts keeps its stream until its launch +line is typed and the node is registered, even when the user leaves during the settle delay: the +request was consumed to get there, so a launch dropped half-way could not be retried, and would +leave an unregistered shell no canvas shows. Going to the background cancels a connect or an +approval wait, but never an attach already sent: that one still arrives, so it can be let go of and +hand its launch on. The terminal screen's use of these rules is only type-checked (its source is +pinned). Both transports also keep the `HostConnection.attach` contract that a cancelled attach +leaves nothing attached: the relay sends `pty.kill` for a stream whose caller gave up while the +request was on the wire (`RelayInteropTest`, through the desktop's real host: its viewer on the node +is released), and SSH detaches a stream whose blocking open finished after its caller was cancelled +(`SshTransportTest`: no tmux client stays attached). + +`InputBarTest` pins the terminal input bar's Send (`A34`, `A41`). While no stream is attached +(connecting, disconnected, ended), nothing is sent: the screen keeps the draft and an armed Ctrl +stays armed, instead of clearing text that reached nothing. Attached, Ctrl plus one character sends +that control byte alone, and anything else goes as a paste followed by Enter. The screen disables +Send, the Resume offer and the sending key chips while nothing is attached; that part is only +type-checked. An unanswered Resume offer stays on screen, disabled, through the reattach, and can +be tapped again once the reattach has settled it (see `ResumeOfferTest`). + +`TerminalPageTest` pins what happens when the terminal WebView's renderer process goes away +(`A45`). The app handles `onRenderProcessGone` instead of being killed with the renderer: it +detaches the stream, destroys that WebView and builds a new one. Each page has a generation, so a +callback the dead page posted just before the loss cannot mark the new page ready, and JavaScript +queued for the dead page is dropped rather than replayed into the new one. A paint offered before +the new page exists is kept for it. A renderer the system killed is reattached automatically once +the new page has reported its size. The renderer keeps WebView's default priority while the terminal +is visible and has it waived while it is not, so a kill in the background is expected: it is not +counted, and the terminal is reattached when the screen is started again. A crash is not reattached, +because the reattach would repaint the same screen: the screen offers "Reopen terminal" instead. More +than two kills of a visible terminal within a minute (on a monotonic clock) fall back to that offer +too. Only that automatic reattach builds the new WebView at once; otherwise it is built for the next +attach something asks for, so a page whose renderer dies as it loads is not rebuilt and lost in a +loop. A screen that was showing an answer with its own button (the session ended, the connection +dropped, "Open through the relay", or an earlier "Reopen terminal") keeps it, and nothing +reattaches unasked: over the relay, an attach to a pane that has exited creates a new, empty +session. The offer says the session is still running only when a stream was attached. The WebView +handling itself is only type-checked. + +`ResumeOfferTest` pins what the terminal screen offers to type after an attach (`A15`, `A76`). A cold +attach (the computer rebooted; only the relay creates a session) gets the cold-restore line: `cd` into +the node's folder, its managed account, and for Claude the permission mode. A Sleeping (Eco-hibernated) node opened over direct +SSH gets the desktop's own wake line instead, with no `cd` and no account prefix, since the pane's +shell is the one the CLI exited back to (again the permission mode for Claude only). It is offered +only while a shell owns the pane: the phone reads the pane's foreground command +(`#{pane_current_command}`) and requires one of the desktop's own shell names (`isShellCommand`, +src/shared/agents/pane.ts, pinned by the test), the gate the desktop's wake keeps. The Sleeping flag +alone is not enough, because it can outlive the sleep. Until the A76 review the desktop cleared it +in the mirror only through its own wake, so a CLI resumed any other way stayed Sleeping there (codex +reports its start as a live state, which the renderer cleared only in its own store). The renderer +now reports that clear and the mirror applies the same rule to the hook events it records, but an +older desktop does not, and with the desktop app not running nothing hears the resumed CLI at all. So after the phone wakes a codex session, the node can still +read Sleeping, and without the pane check every later open would offer `codex resume` into the +running CLI, as a prompt. Accepting a wake clears the prompt's line first (Ctrl-U, the desktop's +kill-line) and re-checks, at the tap, that the node is still Sleeping and that a shell still owns +the pane. A shell outside the desktop's list (nu, pwsh) gets no offer. Through the relay a Sleeping +node gets no offer: the attach already asked the desktop to wake it. A shallow "Pause session" is +Sleeping in the mirror (it carries no `paused`), so it too gets only the offer, which is the explicit +Resume the desktop's PAUSED chip is; a deep pause leaves no Sleeping flag and gets nothing. +A reattach of the same screen (the stream dropped, the app went to the background) is warm, because +the cold attach created the session, so an unanswered resume is carried through it rather than +re-derived from the attach: it is kept while the computer still builds that same line and has heard +nothing from the node since (its mirror entry's `updatedAt`), re-listed right after the reattach, and +dropped otherwise, since a CLI started in the pane meanwhile would take the line as a prompt. Until +the reattach has settled it, it cannot be tapped. A wake is not carried; every attach re-derives it. +Leaving the screen and opening the node again is a new screen, whose warm attach offers nothing. +`SshTransportTest` runs the offer end to end against the real tmux: the line it types starts the +stand-in CLI in the node's own folder even with a half-typed line left at the prompt, and a stand-in +codex the phone woke, still running under a flag nothing cleared, is not offered the wake again on +the next open. The banner +itself is only type-checked. + +`TerminalKeyboardChipTest` covers the key row's ⌨ chip (`A46`), which used to leave the soft keyboard +down: it only called `focus()` in the page. The chip now releases the input bar's focus, gives the +WebView Android's focus, moves the page's focus onto xterm's textarea (blurring it first, because +Blink ignores `focus()` on the element that already has focus, which is the usual state after a tap), +and then asks `InputMethodManager` for the keyboard, after the next frame, so the focus change has +been processed first. The page half runs the real `terminal.js` in node against a textarea stub that +follows Blink's rule. The Android half cannot run on a JVM, so the test pins its order in the source. +The signed-beta Pixel follow-up passes checklist item 24: the keyboard comes up and stays up +in all three focus states, with actual software input reaching the pane rather than the draft. +Other Android/device variants remain untested. + +`SettingsLeaveTest` covers leaving Settings (`A44`). The system back (gesture or button) used to pop +the screen without storing the edited phone name or relay API address; only the top-bar arrow stored +them. Both now run one `leave()`, which stores, then pops. A screen taken away without a back (a +notification tap replaces the stack, a pairing link pushes its screen on top) stores the edits as it +goes, silently. Nothing is stored per keystroke. The relay address must be a full `https://` URL +(`ApiBaseSetting`: a host, no query or fragment); one that is not is left unstored, the field says +so while it is being typed, and leaving shows a message. An unusable address an older build stored +is kept without a message, but the field flags it too. Leaving without an edit stores nothing, and +the built-in relay address is never stored: "Reset to default" forgets the stored address instead. +A phone that stores none follows the default of the build it runs, so storing the default would pin +it to this build's for good. The address rule and the leave decision are unit-tested; the wiring is +pinned in the source, and whether the back gesture reaches it is a device check. + +`SourceControlTest` and three relay interop tests cover the Source Control screen (`A29`). The app +already had the client half of the desktop's git bridge (`HostConnection.git`) but no screen used +it. A project's Source control (from its heading on the Sessions tab, or beside the Board's project +picker) now shows the status split into conflicts, staged, changed and untracked files, a file's +diff on either side, stage and unstage (one file or a whole section), a commit of what is staged, +push and pull, and the last 50 commits. The folder is the project's `cwd` from `projects.list`; +there is no free-form git, only the bridge's typed verbs. The interop tests run them through the +desktop's real `GitService` over a repository in the project's folder, from the status to a pushed +commit, and check that the bridge's refusals ("cwd is outside the shared project roots." for a +folder outside its jail, `..` included; "git is not served on this host." for a desktop without the +bridge) reach the phone as those sentences. A git command that fails on the computer is an answer, +not an error: `ok: false` with git's own message, which the screen shows as it is. The desktop's +status sends `U` both for an untracked file (porcelain `??`) and for a path git reports as unmerged, +and has no field telling them apart; an unmerged path is the one it sends in BOTH lists. The phone +takes those out into a Conflicts section (git's `UU`, `AA`, … with its wording), opens one as plain +`git diff` (the combined diff with the conflict markers, never the untracked form, which shows it as +a new file) and offers no Stage for it, since `git add` would mark it resolved, markers and all; +Commit says to resolve them first, as git would. An interop test drives the real `GitService` over a +merge that conflicted. The unit tests pin the reading of each reply (a reply of another shape says +so instead of showing an empty repository), every unmerged state, the diff colouring (a `+++` inside +a hunk is an added line, not a file header; a combined diff's two marker columns; the view keeps the +first 4,000 lines and says how many it left out), the parameters each verb sends, and that every +write (stage, unstage, commit, push, pull) waits three minutes rather than the usual 30 s: the +desktop sets no limit on them, and a commit runs the repository's hooks, a stage its clean filters. +A write that still gets no answer (that wait, or a connection that dropped) says it may still be +running or have finished there, and the screen reads the status again after every write, failed or +not. Whether it can open at all is decided before any request (`SourceControlGate`): a project with +no folder, one of the desktop's SSH projects (the listing names its folder, but that is a path on +the host the desktop reaches over SSH, which the bridge's jail of this computer's own project +folders normally does not include), and a computer the phone reaches only over SSH with no relay leg +each get their reason on the screen. The screen is only type-checked; its wiring (the routing +decision, the gate, every call through `connectionFor(Capability.GIT)`) is pinned in the source. + +`InboxNotificationTextTest` pins what an Inbox notification says (`A52`). An approval's notification +used to carry the desktop's tool summary (the command's first line, a file path, a fetched URL), and +a finished turn's the agent's last message. Android shows a notification's full content on a secure +lock screen under its default setting, and a public version changes that only for users who hide +sensitive content, so the event's own text is now left out unless the user turns on **Settings → +Show details in notifications** (off by default). The title keeps the session and whether it needs +you or completed ("Needs you — build-bot"), and the text says the kind ("Needs approval", "Has a +question", "Finished", "Interrupted"). A public version with only that title and the computer's name +is always set. The iOS app does get the detail: the desktop sends the event's title and detail in +the APNs push body (`src/core/push-notify.ts`), and what iOS shows on its lock screen is iOS's own +preview setting. That is a difference in platform defaults, not an Android-only leak. The words are +unit-tested; the notification's use of them and the setting's default are pinned in the source, and +what a lock screen shows is a device check. + +`LiveNotificationsTest` covers when notifications are posted (`A73`). The app said they were live +every 8 s while a computer was open, but only the 15-minute background check ever posted one; the +in-app refresh only updated the listing. Now every listing that arrives (the 8 s refresh of the +computer on screen, a change that computer pushes, the background check) runs the one announce path, +so notifications are live for the computer whose screen is open. All computers now watches every +paired host while visible, through the same announce path. What the user is looking at is left +out and recorded as seen instead, so no later check announces it: every event while that computer's +Inbox tab is on screen, and what a session's terminal shows while it is attached. That is recorded +even with notifications off, so turning them on later does not announce it either. Recording is +permanent, so it must never claim more than the screen shows (the review of A73): a held hook-reply +approval (it carries a `pendingId`) is announced with its terminal open, because Claude paints that +prompt only once the hold ends; a terminal showing an overlay instead of its pane (ended, relay +offer, approval code, lost view) hides nothing; and one still connecting leaves its session's events +for a later listing, which the attach settles at once from the latest listing. Other computers are not +polled while one is open, so theirs still come only from the background check, and the Settings +text, the README and the notifier's comment now say exactly that. The All computers screen (`A55`) +re-lists every paired computer every 8 seconds while visible; its first dial and subsequent polls +use AUTO, preserving a refused or unanswered approval until that computer's Try again. While its +Inbox tab is on screen every computer's Inbox counts as on screen. A computer the user just left is +one of them: its connection can stay open until it drops or the background check closes it, and a +change it pushes meanwhile is no longer re-listed (it used to be, and so announced live; the review +of A73). What that push carried is not recorded as seen, so the background check announces it. The +announce costs no network call (the listing already arrived), and the check writes the phone's +seen-log only when something is new. The decision and the screen bookkeeping are unit-tested; the +wiring into the refresh, the worker and the two screens is pinned in the source, and whether a +notification appears on a phone is a device check. + +`SeenLogTest` covers the seen-log itself (`A48`, and its per-computer follow-up). It keeps each event +id with the time the phone last saw it (or the event's own time, when the computer's clock runs +ahead), and drops an entry a day after that, past the 6 h announce window, never by count (bar a +memory backstop no real Inbox reaches), so nothing still eligible is forgotten. It is kept PER COMPUTER: a desktop's +event id is `-` with a counter that restarts with each app run, so two computers can mint +the same id in the same millisecond, and with one phone-wide log the first computer's event would +have silenced the second one's notification. Every entry is filed under the pairing id of the +computer whose listing it came from, with the node its event belongs to. The node is what keeps one +event that reaches the phone through two pairings from being announced twice: a desktop's listing +carries the nodes of its SSH projects, and the SSH host it drives, when the phone added that one too, +lists the slice the desktop pushes there (`A27`), with the same event ids. So an event also counts +as seen when another pairing recorded the same id for the same node, and meeting it that way records +it under this pairing too, so forgetting the other one later does not announce it again. A real +collision, two computers minting the same id for different nodes, stays apart; the same id for the +same node on two computers would need one node id on both (a canvas committed to two repositories) +and the same millisecond and counter value. Forgetting a computer drops its entries; pairing the same +computer again (a new pairing id) carries them over, since its ids continue, so pairing again +announces nothing a second time; and nothing is recorded for a computer no longer paired. The +phone-wide log of the previous build (and the older bare id set) migrates on first use as seen for +EVERY computer, because it never said which computer an entry came from; those entries age out a day +after they were last seen and nothing new is added to them, so an upgrade announces nothing again +and the old phone-wide behaviour lasts only that day, for those ids alone. The log's rules are +unit-tested; which computer each caller names is pinned in the source. + +`NotificationActionsTest` covers answering from a notification, and where its tap goes (`A25`, the +in-app part). A notification had no actions, and its tap opened only the computer's Inbox. Now an +approval carries Approve and Deny and a single-select question its options, and the tap opens that +session's terminal, with the computer's Inbox one Back away. Which actions an event gets is the pure +`InboxNotificationActions.plan`, on the Inbox card's own rules: Approve and Deny only where +`QuickActions` can answer from outside the session (a held ticket, or a claude prompt), options only +where `QuestionChoices` lists them as answers, and at most three, Android's limit. A question with more +options, a multi-select one, another agent's approval without a ticket, and a computer the phone could +reach only through a first relay handshake get Open instead. With "Show details in notifications" off +the options are labelled "Option 1", "Option 2", … (`A52`). A tap reaches an unexported receiver +through an explicit, immutable PendingIntent. The receiver takes the actions off the notification +("Approving…", so a second tap cannot send a second answer) and hands the answer to expedited work, one +per event. The work connects the way the background check does and never makes a first relay +handshake, since the user tapped but is not looking at the app to compare the desktop's code (`A05`): +it reuses a connection already open, or takes the SSH leg or a relay that has already approved this +phone; otherwise the notification says to open the app. The phone cannot tell whether it is on a +computer's network before it dials, so a computer paired with an SSH key always gets the answers; one +tapped away from its network goes through a relay that has already approved this phone, or is not +sent ("couldn't reach"). Open replaces the answers only for a computer reached through the relay +alone that has not approved this phone; the listing that raised the notification normally came over +that relay and approved it. The answer is `QuickActions`', with its re-checks (still unresolved, the +node-state rules, the ticketed path of `A38`, and keys only for a card the computer still lists, since +a notification outlives the card in the desktop's feed), and it is sent at most once: a run +WorkManager starts again by itself after an interrupted one (the system stopped the work, or the +process died during it) sends nothing and says the answer could not be confirmed +(`InboxNotificationActions.answerOnce`, on the run attempt count). The answer, the background check +and the screens share one connection per computer, which is closed when a background job ends only if +no other job and no screen still uses it (`ConnectionUsers`). The notification then says how it went. +"Approved.", "Denied.", "Answered with option N." and "Already handled." go away after 10 s; a +timed-out hold, a request only the session can answer, an answer not sent (and why) and one that could +not be confirmed stay, and their tap opens the session. +The app does not open the session by itself at that point: Android 12 forbids starting an activity +from a notification's receiver or service (a "trampoline"), and Android 10 forbids starting one from +the background, so the notification says "Tap to …" instead. An answering action needs an unlocked +phone: Android 12 and later ask for the unlock before they send it (`setAuthenticationRequired`); +Android 11 and lower send it from a locked screen, so there the receiver refuses and says to unlock. +The plan, the answer run against `QuickActions` (every answer the plan offers is one `QuickActions` +sends, and none is typed for a card the feed dropped), the work's whole run (a rerun sends nothing, +every dial happens while the connection is held), the outcomes and the hand-off's encoding are +unit-tested, and `ConnectionUsersTest` covers the shared connection (two overlapping jobs close it +once, after the last); the PendingIntents, the receiver, the work, its dial trigger and the tap's +route are pinned in the source, and whether the actions show and answer on a phone is a device +check. + +`AllComputersTest` covers the All computers screen and the needs-you counts (`A55`). iOS merges its +Agents feed across every paired connection and shows one Usages section per connection that reports +usage (docs/mobile-usage-inbox.md); Android had one computer's Inbox and Usage, inside that +computer's screen, and the computers list showed no count. Now each computer's row shows how many of +its approvals and questions are open, read from the listing its session already holds (the open +screen's, the background check's, or the last one this process made), so the list dials nothing; +before a computer's first listing in a process its row shows no count. With two or more computers +paired, the list starts with "All computers": every computer's Inbox and Usage on one screen. The +rules are the pure `AllComputers` and `InboxFeed`: events of every computer newest first (a stable +sort, so the same moment keeps the computers' order), open cards above the archive, the working +sessions in the computers' order rather than by time (their time moves with every tool call), every +card keyed and labelled by its computer (a name two computers share gets `user@host`, and the same +computer paired twice a number), and one Usage section per computer whose mirror has accounts in its +`usage` block. A computer's own Inbox tab is the same `InboxFeed` of that one computer, so the two +cannot sort or count differently. Opening the screen (and coming back to it, or Refresh) re-lists +every paired computer once through its normal connect path, so a relay leg goes through +`RelayApprovalGate`; it is not polled after that. That re-list is the app's own foreground refresh +(`Trigger.AUTO`), not a tap on each computer: a computer that has never approved this phone shows its +approval code on this screen, labelled with its computer, but one whose approval was refused or went +unanswered keeps its hold, and says so with a Try again of its own (`Trigger.USER`). Lifting every +hold on every visit would raise the desktop's dialog again, each time, on a computer the user did not +ask about and may not be at (`A05`/`A23`). A computer whose last listing failed says so too, with the +same Try again, also when the failure dropped its connection (an error the phone did not expect leaves +the session idle): nothing on this screen re-lists it on its own, so its cached cards would otherwise +stay on screen looking current. A computer's own screen shows that error under the same rule +(`ConnState.showsListError`). Open, Approve and Answer on a card go to the card's own +computer: its session and connection, and its relay leg for a node of that computer's SSH projects. +The cards are the Inbox tab's composables (`InboxFeedList`, `UsageCard`), not copies. The rules are +unit-tested; the screen, the row count and the route (saved in the back stack as `["all"]`, a name +of its own, so every earlier entry restores as before and a build that does not know it drops just +that entry) are type-checked and pinned in the source, and how the screen looks and answers on a +phone is a device check. + +`PhoneIdentityTest` and `BackupRulesTest` cover what leaves the phone (`A51`). `allowBackup="false"` +stops cloud backup, but an app that targets Android 12 or later is still copied by a +device-to-device transfer unless its data extraction rules exclude it. The transfer carried +`nodeterm.hosts` (every paired computer, its SSH pin and the phone's relay deviceId) to the new +phone, while the Keystore-sealed secrets could not follow. The manifest now names rules that exclude +every domain from both cloud backup and device transfer, so a new phone starts unpaired. +`BackupRulesTest` pins those files and checks that every preferences file the app opens is covered; +what a real transfer copies is a device check. Independently of the rules, the relay deviceId now +goes with the box key (`PhoneIdentity`): when the key has to be created (absent, or provably lost), +the stored deviceId is removed first, durably, and a deviceId is minted only once the key exists. A +phone whose key was lost therefore registers with the relay backend under a new id instead of +re-registering the old one without its previous device token, which the free tier can refuse (per +the desktop's note on `priorDeviceToken` in `pairing-service.ts`; the backend is not in this repo). +From this build on, it also no longer shares a device row with the phone it was copied from, so +removing one pairing on an entitled desktop cannot revoke the other phone. The old row is left unused +on the backend, as after an uninstall. An existing install keeps its id while its key still opens, +so a phone whose key an older build already replaced keeps the id it had (and any row it shares) +until the app is reinstalled. + +`KeyboardInsetsTest` covers the soft keyboard on Android 15 (`A77`). The app targets API 35, so on +Android 15 its window is edge-to-edge whether it asks or not: the keyboard no longer resizes the +window, and its inset reaches Compose instead. The terminal screen added the keyboard's inset on top +of the Scaffold's padding, which already held the navigation bar, so with the keyboard up the +navigation bar was counted twice. Pair and Settings, whose text fields sit in a scrolling column, +made no room for the keyboard at all. All three now go through one helper (`aboveKeyboard`), which +consumes the Scaffold's padding before it adds the keyboard's inset, and which comes before the +scroll, so the keyboard shrinks the visible part instead of padding the end. The app still does not +call `enableEdgeToEdge()`: on Android 8 to 14 the window keeps fitting the system bars and makes room +for the keyboard itself, as before, and the helper adds nothing there. Text fields in dialogs are left +alone: a dialog is a floating window, and the framework clears inset fitting only for non-floating +windows (read from Android 15's `PhoneWindow` classes, not measured). The test pins the helper, that +nothing else asks for the keyboard's inset, and that every Scaffold body with a text field uses it; +how the screens and the dialogs look with the keyboard up is a device check. + +`DictationTest` covers dictation into the terminal's input bar (`A59`). The phone had none: iOS and +the desktop dictate with on-device Whisper, and Android had only the keyboard's own voice typing, +which still works in the field. Now a mic button beside Send runs Android's `SpeechRecognizer` and +writes what it hears into the draft, and never sends it: the draft reaches the pane only on Send, +the desktop's rule for its own dictation ("nothing auto-submits"). The rules are the pure +`Dictation` machine. Its phases are idle, listening, partial (words heard so far, shown in the draft +as they come), finishing and an error with a short sentence. A tap on the mic while listening asks +for the final result (finishing), and a second tap cancels, so a recognizer that never answers +cannot keep the button stuck. The final result is an event that fills the draft and returns to idle. +The draft as it was when the dictation started is kept, and the heard words are appended after a +space (none after a trailing space or newline). Each partial result replaces the previous one rather +than piling up, and a blank final result keeps what the partial results showed. Each time dictation +writes the draft the cursor goes to its end, after the heard words, so typing after a dictation +continues there. (The draft was a plain string at first, and the field's string overload keeps the +previous cursor when the text is set from code: after dictating into an empty draft the cursor stayed +at the start, and the next keystroke went in front of the words.) Any other change to the draft's +text while listening (typing, or Send clearing it) ends the dictation, so its late results cannot +bring back text that was sent or deleted; moving the cursor changes no text and ends nothing. +Whatever the draft shows when a dictation is cancelled or fails stays in it. Each of the 15 +`SpeechRecognizer.ERROR_*` codes maps to a failure with a short message, and any other code to a +generic one; a test reads the codes out of the android-all jar the type-check compiles against, +and is skipped where that jar is not in the Gradle cache (CI). The microphone permission (`RECORD_AUDIO`) is asked for on the first tap. A refusal says +how to allow it, and the manifest does not require a microphone. The button is hidden when +`SpeechRecognizer.isRecognitionAvailable` is false, which on Android 11 and later needs the +manifest's `` entry for `android.speech.RecognitionService`. The screen cancels a dictation +when it stops (nothing listens in the background) and releases the recognizer when it goes. +Settings now offers a searchable language/region picker (A114). System default omits +`RecognizerIntent.EXTRA_LANGUAGE`; an explicit choice sends its canonical BCP47 tag, frozen for +that utterance. The locale catalogue describes language names, not installed-model support. +Native unsupported/unavailable errors remain visible. The desktop defaults to `auto`, Whisper's +own detection. The recognizer is +the phone's recognition service (Google's on most phones), which may send the audio to its servers, +unlike Whisper. The machine and the error codes are unit-tested; the recognizer's wiring, the +permission request and the manifest are pinned in the source; how the dictation sounds and behaves +on a phone is a device check. + +## Device checklist + +A later Pixel 7a beta21 pilot verifies recovery from a 45-second stop of one old SSH peer, +retained draft text/glyphs and one subsequent Send/application ACK. This narrower case does not +pass item 14's whole-host/network scope or change the original 64-row results above. See +[the bounded receipt](#silent-ssh-peer-recovery-and-single-send-2026-10-08). + +Partial results are recorded in "What is verified, and how": beta 10 / code 11 is installed on +the intended Pixel 10 Pro, with its exact APK hash/retained signer, non-debuggable metadata, +install identity, notification grant and its saved desktop-issued pairing/relay reconnect/input +preserved. The update preserves one owned second SSH pane, then the focused A91/A94 two-End/Home +recreation cycle passes. The three-shell update is historical beta8→9 proof. Focused A90 Home/project/ +custom creation, cwd/input/history, restart/reconnect and exact End pass on beta 8/9; item 32 is +Partial; beta 10 relay plain-shell creation/input/End also pass. The tally is 10 Pass / 22 Partial / 32 Pending; managed and cellular creation +remain open; A91's otherwise-empty-host variant passes on Oct4. The following historical beta-6 evidence uses Android 17 / +API 37 and Vanadium WebView `154.0.8037.92.0`, with manual SSH key/pin authentication, 17 real +projects listed and basic terminal input executed. The wrong +MI8 installation and its newly authorized SSH key were removed. The terminal-sizing/history +failure `A85` is fixed in code-3 beta, with a 52×45 viewport and pre-attach tmux history visible after +swiping. The code-4 scroll mitigation update also preserved SSH registration/key/pin and notification +permission and verified reduced drag gain, reversal and Esc cancellation. Beta 4 delivered more +movement but lacked momentum; the then-installed code-6 beta implements bounded fling/native stop but +still loses continued swipe/release events (`A89`). Code 7 received a same-signer update preserving +app data and notification permission; SSH reopened with the retained key/pin at 56×48. Controlled +continuous drag, coast and Esc stopping pass; a held touch stops coast at a stable 56×25 viewport. +Earlier IME-resizing touch checks were inconclusive; the user confirms normal drag/coast both +work now. The current verified Pass items are 1, 18, 19, 21, 22, 24, 36, 38, 39 and 51; all other items +are Partial or Pending as recorded in the 64-row table above, with named conditional SKIP variants. +Real SSH background/detach recovery, encrypted paste pairing, relay-only hosted browse/input and +the synthetic shipped-hook lifecycle are verified; +outage/cellular-relay and remaining lifecycle +checks stay open. The user resumed remaining Pixel release checks on 2026-10-04 after the hike. Run these +on a real phone against a real desktop and record, for each item, pass or +fail, the phone model, its Android and WebView versions, the desktop's OS and nodeterm version, and +the route (network or relay). Write the results into "What is verified, and how" above, and turn each +failure into a new finding. Every item names the audit finding it checks; `A65` marks the baseline +checks that finding asked for, where the feature table is the only claim. An item that needs +something a tester may not have (a Windows desktop, Android 15, a second phone, a release key) says +so; skip it and record why. The list starts from the 23 items the handover drew up and adds what each +later fix left to a device. + +### Install, update and what stays on the phone + +1. For beta readiness, install two consecutive private betas using the same private signer and + increasing version codes; pair on the first and update to the second without uninstalling, keeping + the pairing. The installed beta 10 / code 11 already retains the desktop-issued pairing; + test its next same-signer higher-code update. Do not downgrade or uninstall the working app. + Also check the committed-debug-key path separately with two CI debug APKs (artifact + `nodeterm-android-debug`). Migrating from debug to private beta needs one deliberate uninstall + because the signing certificates differ; revoke the stale phone entries and pair again. + *(A10)* +2. Pairings survive a restart: force-stop the app, reboot the phone, reopen the app. The computer is + still listed and connects on both routes without pairing again, the desktop's Settings → Phone + still shows one entry for this phone, and no new relay approval is asked for. *(A24, A65)* +3. Uninstall, then install again: the app starts with no computers (its Keystore key and data went + with it). Pairing the same computer again works, relay included, also on a desktop without Pro. + Revoke the stale entry on the desktop. Then do it once more with Clear storage (Android Settings → + Apps → nodeterm → Storage) in place of the uninstall. After either one the phone has a new + identity, which the README's recovery after adb access depends on: on a macOS or Linux desktop the + key the new pairing adds to `~/.ssh/authorized_keys` differs from the stale entry's, and the box key + the desktop pins (`remote-approved-devices.json` in its app data) differs from the old one. + *(A51, A65, A50)* +4. Android 12 or later, with a second phone: a device-to-device transfer ("copy apps and data" in the + new phone's setup) leaves the app there with no computers and no pins. Pair it too, then revoke + one of the two phones on an entitled (Pro) desktop: the other keeps working. A cloud backup + restored onto a fresh install brings back nothing of the app either. *(A51)* +5. With the private-beta signing key: install the signed, minified release APK and run the pairing, + SSH, relay, OSC 52 copy, links and Copy sheet, and background-notification items on it, since that is where code R8 could + have broken runs (BouncyCastle's provider tables on the first connect, the WebView bridge, the + WorkManager worker). An error message names a real exception class, not an obfuscated one. + `adb shell run-as dev.nodeterm.android` is refused on it, while on the debug APK it opens a shell + in the app's data, which is what the README's debuggable warning says. *(A37, A50)* + +### Pairing + +6. Pair by QR from the Pair screen's scanner with a macOS desktop, then a Linux desktop, then a + Windows desktop. The Windows QR carries `"ssh":false`, so that pairing is relay-only, and a failed + relay mint pairs nothing. *(A65)* +7. Pair by pasting the code's text, and by scanning the desktop's QR with the phone's own camera app. + The desktop's QR is raw JSON by default, which a camera app shows only as text: check that nothing + opens. For the camera, first choose "Scan with the phone's Camera app instead" under the QR in the + desktop's Settings → Phone (the quick-pair popover has no such switch); that QR carries the + `nodeterm://pair?code=…` link, which the camera hands to the app. Scan it once with the app closed, + once with it open on another screen. Deny the camera permission: pasting still pairs. *(A65)* +8. With remote access on, against a current desktop: the Pair screen says "Remote access is on", the + pairing ends with "Paired, and approved for remote access.", and the first relay connect later + raises no SAS dialog on the desktop. Revoke the phone there (Settings → Phone → Revoke) while it + has a terminal open over the relay (route Only through the relay): that terminal ends at once, SSH + is refused, and the phone's automatic redial shows a code for a few seconds and then says the + computer did not approve it, while the desktop shows no dialog at all; it stops dialing until Try + again, which ends the same way. Restart nodeterm on the desktop and tap Try again: now the SAS + dialog appears (deny it). Against an older desktop the toast says an approval is still owed, and + the first relay connect shows the code. *(A07, A07-revoke, A93)* +9. A pairing code whose computer does not answer (the desktop quit after showing the QR, or the phone + is on another network) ends within about 45 s with a sentence, not an exception name, and Back + during the wait works without a hang. An expired or already-used code shows the desktop's one-line + refusal. *(A54)* + +### Connecting + +10. On the LAN (route Automatic): the Sessions tab shows the desktop's projects and sessions, grouped + Needs you / Running / Sleeping, with activity and context %. *(A02, A65)* +11. Pair with a current desktop (macOS and Linux), then connect over the network: the first connect + is accepted (the key the computer's sshd serves is one the pairing named) and pins it, and later + connects use it silently. Then present a different key at that address (another SSH-running + machine takes the desktop's LAN address, or the desktop's host keys are regenerated): on + Automatic the phone refuses SSH, connects through the relay and keeps a warning on the host + screen that names "Only through the relay"; on "Only on my network (SSH)" it stops with the + warning. Do the same once between pairing and the first connect: the phone says the key is not + one the computer reported, and pins nothing. With a current desktop, move it to another address + on the LAN while the phone is away and connect through the relay, then come back: the next + connect on the network dials the new address. Regenerate its host keys instead: the next connect + on the network is refused and goes on to the relay, and the host screen's warning adds that the + computer confirmed the key its SSH server presented; the connect after that (once the relay + connection has ended) accepts the new key without pairing again. With sshd serving a host + certificate, the pin is the certified key (`ssh-keygen -lf` of its `.pub`), and a renewed + certificate connects silently. On a Linux computer, make sshd serve only a key the desktop + cannot see (a `HostKey` outside `/etc/ssh` with no `.pub` beside it) and pair: the phone refuses + SSH, connects through the relay on Automatic, and its warning says that pairing again changes + this only if the keys changed. + *(A49, A74)* +12. On cellular, off the LAN: connect through the relay. The desktop shows the SAS dialog and the phone + shows the same code; approve. Reconnect later: no second prompt. On another pairing press Deny: the + phone says it was not approved and does not dial again until Try again. Briefly enable airplane + mode during connection, then recover: a stalled relay token request fails within about 30 s + rather than connecting indefinitely; leaving the screen cancels it, and reopening can connect. + *(A65, A30, A81, A93)* +13. Leave the phone in the background for 15 minutes or more with a paired computer that has never + approved it over the relay: no SAS dialog appears on the desktop. *(A05, A17, A23)* +14. Put the desktop to sleep (or pull its network) while the phone is connected over SSH: within about + 45 s the phone notices, and on Automatic it moves to the relay or says the computer is offline. + Waking the desktop reconnects. *(A31)* +15. Open a computer and go Back before it has connected, several times in a row: no connection error + is recorded for it, and the next open connects normally. *(A20)* +16. Change a computer's route in Settings → How to reach each computer and check that the next connect + follows it; forget a computer; pair two computers and move between them. *(A65)* +17. Pair while the desktop's remote access is off (the toast says the phone is approved for remote + access once the computer offers it), then turn it on and open the computer over the network: the + host list gains "From anywhere" without the app being restarted. Then take the phone off the LAN + with the app in the background for 15 minutes or more: the background check reaches the computer + through the relay, and the desktop shows no SAS dialog; its `remote-approved-devices.json` now + lists the phone's key. Open the computer off the LAN: it connects through the relay, again with + no dialog. Revoke the phone on the desktop, pair it again with remote access off, and adopt the + relay as above: still no dialog. The host list stays smooth while a connection is being made. + With a legacy pairing lacking a relay key association, approve its first relay handshake and + verify its retained SSH identity repairs only that entry. Revoke it: only its last-key session + closes; another pairing authorizing the same key stays retained. Missing seed/key attribution + must keep browsing usable and revoke unconfirmed; a network close during proof must not report + connected or falsely report Deny after approval. These additional physical cases are pending. + *(A47, A65, A07-late, A93, A118)* + +### Terminal + +18. Open a terminal over SSH and type with the soft keyboard. Rotate the phone. Use A−/A+ and every + key chip (Esc, Tab, ⇧Tab, the arrows, ⏎, ⇧⏎, ^C, ^D, ^R, ^L, Home, End, PgUp, PgDn): the + connection survives all of it. *(A01, A04)* +19. Non-ASCII renders over SSH: Claude's rounded borders, accented letters, CJK, emoji. *(A03)* +20. Swipe to scroll the tmux history. A copy the pane makes reaches Android's clipboard (OSC 52) with a + "Copied N lines" toast: in tmux's copy-mode (Ctrl, then b, then [ from the key row and the input + bar), or from an application such as vim (`"+y`). Record slow/fast drag gain, reversal and + movement after finger lift, stopping on a new touch, and automatic focus/mouse reports during a + drag. Keep moving through terminal redraws and check that the remaining drag and release still + work. With inert native history open, fling, then hold, move or cancel a touch on the displayed + Live button: momentum and pending scroll stop immediately, without closing history until click. + Click Live to return to live output; a late old page must not reopen history. + *(A65, A85, A86, A87, A89, A130)* +21. A large OSC 52 copy. In the pane, run + `printf '\033]52;c;%s\a' "$(head -c 150000 /dev/zero | tr '\0' x | base64 | tr -d '\n')"` + (the desktop's tmux passes an application's OSC 52 on): the phone says it is too large to copy and + nothing crashes. With 450000 in place of 150000 the page refuses it before it crosses the bridge, + with the same message. With 90000 it is copied, or, if the system refuses a clip that size, "Could + not copy: too large for the clipboard." shows; never a crash. *(A53)* +22. Invalid OSC 52 is ignored silently: a payload that is not base64, one with no `;`, a `?` read + query, and a selection field longer than 16 characters copy nothing, show nothing and leave the + clipboard as it was. *(A53)* +23. The terminal uses its visible viewport height on initial open, after changing font size and + with the keyboard shown/hidden; its host receives more than one row when the viewport has room. + "Sized to another screen · Fit this screen" appears when the desktop's view of the session is + larger, and Fit works. *(A65, A85)* +24. The ⌨ chip raises the soft keyboard, and it stays up, in three states: right after the terminal + opens, before the page was ever touched; after tapping the terminal and then dismissing the + keyboard (the page's input already has focus); and while the input bar has focus. The keys then + go to the pane, not to the input bar. *(A46)* +25. With a terminal open, turn on airplane mode: the input bar's draft stays (Send, the sending key + chips and Resume are disabled, and the keyboard's Send leaves the text in place). Turn it off: the + terminal reattaches by itself ("Disconnected. Reconnecting…" clears) and the draft then sends. Arm Ctrl and send "c" from the input bar: the + pane gets ^C, with no Enter after it. With continuous numbered output, background and resume + repeatedly: each reattach paints its own snapshot without replaying the previous viewer's + buffered bytes. A current terminal's final tail remains visible before its exit. After a known + SSH/tmux viewer ends, its last visible pane and Copy/links stay on the same page; a retired EOF + cannot restore them into a new viewer. *(A41, A34, A36, A131, A133)* +26. The terminal WebView's renderer goes away. A kill while the terminal is on screen: the app stays + open and the terminal is rebuilt and reattached by itself; a third kill within a minute offers + "Reopen terminal" instead. A crash offers "Reopen terminal", never an automatic reattach. A kill + while the app is in the background: the terminal is back when the app returns. A kill while the + screen shows "The session ended", "Open through the relay" or "Reopen terminal": that answer stays + and nothing attaches. One way to provoke them, not tried: on an emulator with `adb root`, `kill -9` + the WebView's renderer process for a kill; a crash needs the renderer itself to crash (for + example `chrome://crash` from DevTools). Old buffered output and commands waiting for page + readiness must not cross into the replacement viewer. *(A45, A131)* +27. Send the app to the background with a terminal attached through the relay, for a few minutes: the + desktop's view of that session is no longer held to the phone's size, and the 8 s refresh stops. + Coming back reattaches. *(A18)* +28. On the desktop, open the project of a session the phone is attached to, so the desktop mounts that + node: on the updated host both relay and direct-SSH phone views stay attached. Repeat both + attachment orders, unequal grids and a desktop remount. Older hosts may detach; Android then + reattaches rather than saying the session ended. New variants remain Pending. *(A13)* +29. A Sleeping (Eco) session opened over direct SSH offers "Wake " while a shell owns its pane. + The tap wakes the conversation, and opening it again (the CLI now running) offers nothing. Through + the relay, opening it wakes it with no offer. Verify capable agents retain their actual host's + project approval policy on wake, including non-Claude agents. *(A76, A103)* +30. Reboot the desktop, then open a session through the relay: the resume offer appears, and Resume + continues the right conversation, in the node's folder, under its Claude account and with the + project's permission mode. Drop the connection before tapping it: the offer comes back with the + reattach and can be tapped once the reattach has settled. Over direct SSH such a session is not + created: the phone offers "Open through the relay". Repeat non-Claude resumes with their + measured approval flags and available old/new Codex vocabulary; unsupported modes keep the + actual CLI default. *(A15, A16, A41, A08, A102, A103)* +31. A session of one of the desktop's SSH projects: over direct SSH the phone offers the relay + instead, and through the relay it opens on that project's host. *(A09, A28)* + +### Sessions, the board and new sessions + +32. New session from the phone (Claude, then a shell), through the relay: the node appears on the + canvas, the agent runs in the project's folder, and its status badges update on both sides; a + Claude permission prompt reaches the phone's Inbox as an approval. Managed Claude accounts are + named by their label or email in the picker, the session row and the Usage card, never by an id. + Repeat against a Windows desktop: the session starts in the project's folder under the chosen + account there too. On a current desktop, verify trusted project env/shell overrides on a cold + relay launch; denied/untrusted executable settings cannot run. Saved owner/account/agent facts + override conflicting create hints, and joining a warm session does not change its launch facts. + Repeat with capable non-Claude agents and their project approval modes; verify the host Codex + vocabulary and unsupported-mode defaults against the actual CLI, without a sandbox bypass. + With an updated enabled Linux/macOS tmux host and beta 14, repeat New session over direct SSH + for a shell and managed Claude account. Confirm real cwd/account/environment/hooks, canvas + registration and Inbox status; dismiss/background during creation, restart the app and reconnect. + Lost replies, a restarted service, changed account/folder or replaced/multiple panes must require + inspection without creating or launching again. A confirmed viewer retains normal reconnect. + This new variant is Pending and does not inherit the earlier relay/plain-shell passes. *(A111)* + Separately, on the intended Pixel's installed beta 10 / code 11 + over manual SSH/WireGuard use Sessions → New terminal → project folder → Create, then create + another in Home; also check a custom absolute folder. Confirm real shell cwd, input and history; disconnect/reopen and + restart the app so both remain in Phone terminals; end only the owned session and confirm + the other survives. Existing desktop sessions/project files/canvas stay unchanged. This + does not verify relay registration, managed-agent launch or account selection. + On an otherwise empty SSH host, end the last owned phone shell: its row disappears after + refresh, while the host stays connected over SSH and New terminal remains available. + *(A72, A33, A14, A39, A75, A90, A91, A94, A102, A103)* +33. New session, then Back within a second of Start (before the launch line is typed), and once more + by sending the app to the background right after Start: both times the node still appears on the + canvas with its agent running, not a bare shell. *(A40)* +34. With the New-session dialog open, close the selected project on the desktop (or remove the + selected account): within a refresh the dialog moves to a project it still offers, or disables + Start with a line saying why; nothing crashes. *(A42)* +35. Wake, refresh, rename and end a session from the phone, through the relay and with the + current Desktop SSH actions service and remote access off. Wake an eligible Sleeping node in + an inactive and closed project without switching desktop tabs; explicit Pause stays respected, + a replaced/exited pane refuses, and an old backend is not restarted. Refresh/Rename receipts + prove nudge delivery; inspect the actual result separately. *(A65, A104, A108)* +36. Board: move a card, add and remove a label, create a new one; the desktop's board updates + without a reload. Repeat through the current selected-profile SSH service with remote access + off, alongside mounted desktop edits, and on Server Board. A replaced/stopped service or lost + receipt never reports success or retries the mutation through another transport. *(A65, A95, A108)* +37. On the Board tab, pick a project and scroll; open a terminal and come back: the same tab, scroll + position and project. Switching tabs and back keeps them too. *(A43)* +38. Kill the app's process in the background (Developer options → "Don't keep activities", or + `adb shell am kill dev.nodeterm.android`) and reopen it: the screen and the back stack are + sensible, and the tab is kept. *(A22, A43)* + +### Inbox, notifications and usage + +39. Approve and deny a held Claude permission from the Inbox within 45 s. Answer one after its + hold has expired: the phone must not report success. On an eligible v2 ticket, confirm the exact + Always allow rule/destination on relay and SSH; verify actual CLI application and its settings + scope. Changed/expired/no-suggestion requests offer no remembered answer and never type 2. + *(A06, A35, A56, A105)* +40. A subagent's approval while its parent waits on a question: Approve from the Inbox answers it, + rather than saying "Already handled." *(A38)* +41. Answer complete held AskUserQuestion cards through relay and SSH: single/multi selections + and 2–4 questions, exact labels, every question required, input preserved and actual CLI result. + Expired/replaced/unsupported tickets never type guessed keys; older phones open held v2 cards. + Separately test an unheld legacy single-select with the pane in copy mode: the answer reaches + the application and leaves copy mode on both routes. Legacy multi-select offers Open session. + Repeat mounted and offscreen/released relay background answers; a missing session must not + type into a longer session name sharing its prefix. *(A12, A57, A65, A78, A79, A80, A106)* +42. Open a finished session on the phone: the desktop's unread dot clears. On a host that runs its + own nodeterm and is driven over SSH by another desktop, repeat for each desktop's sessions while + both sweepers run: the owning desktop's unread dot/Done card clears and the other's pending + acknowledgment is retained for its owner. Repeat with two SSH projects on the same host and + after an owning desktop restarts with an old unresolved Done card. *(A65, A82)* +43. A background notification arrives within about 15 minutes; tapping it opens that session's + terminal, with that computer's Inbox one Back away: at launch, with the app in the background, and + with the app open on another computer. *(A11, A19, A25)* +44. Notification permission on Android 13 or later: deny it at first launch; Settings → Notifications + then reads Off; switching it on asks again or opens the app's notification settings; nothing is + posted while it is denied. *(A21)* +45. Live notifications for the computer on screen: with its Sessions tab open, a turn finishing on the + desktop raises a notification within about 8 s; with its Inbox tab open, none, and none later; + with a session's terminal attached, none for that session, except a held hook-reply approval. + Another paired computer's events arrive only from the background check. *(A73)* +46. Each event notifies once: an event announced once is not announced again by later refreshes, by + the background check, or after the next APK is installed over this one. With a desktop paired and + the SSH server one of its projects runs on added too (item 64), an approval in that project + notifies once, not once per computer, and reading it on either computer's Inbox keeps the other + quiet. *(A48, A27)* +47. The lock screen: with "Show details in notifications" off (the default), a notification shows its + title ("Needs you — " or "Completed — "), the kind and the computer, but no + command, question or last message; turned on, the shade shows those too. With the lock screen set + to hide sensitive content, only the title and the computer's name show there, either way. *(A52)* +48. The Usage tab shows a pace line ("5h usage pace faster", "slower" or "on pace") when a limit's reset + time is known, and none when it is not. Approval, question and done cards show the node's context + ring and "N% context", matching the desktop's meter. *(A58)* + +### Settings and system UI + +49. Settings: edit the phone's name and the relay API address, then leave with the system back gesture: + both are stored (reopen Settings to see). An address that is not a full `https://` URL shows the + field's error and a toast on leaving, and is not stored. "Reset to default" forgets a stored + address. Leaving without an edit stores nothing. *(A44)* +50. Android 15 (edge-to-edge) with the soft keyboard up: the terminal sits right above the keyboard + with no extra gap the height of the navigation bar, and the Pair and Settings text fields scroll + into view above it; the label and rename dialogs look right. On Android 8 to 14 the same screens + are unchanged. *(A77)* +51. Light and dark system theme; a tablet or a foldable if one is available. *(A65)* + +### The relay leg next to SSH + +52. On the same network with Automatic, compare the current SSH actions service and an older + desktop: current Board/Wake/Refresh/Rename work over SSH with remote access off; older hosts + need the allowed relay and show the reason when unavailable. Canvas New still needs the relay. + With Only on my network, no action silently opens relay. A submitted unanswered SSH mutation + is not replayed after reconnect. Desktop-owned SSH-project metadata can use its service; + opening/typing/ending that third-machine terminal still needs its relay. After toggling remote + access, old-host relay availability updates within a listing. *(A26, A108)* +### Source control + +53. Open Source control from Sessions/Board through relay and direct SSH, including remote + access off and Only on my network. Status/diff/history match the actual repository; stage, + unstage, commit, push and pull show confirmed results. Test spaces/newlines/Unicode and + dash/pathspec-like filenames, nested repositories, symlink escape refusal, no-folder and + third-machine projects. Conflicts remain unresolved in the screen; a long hook completes + within the write deadline. A rejected remote mentioning set-upstream never triggers a second + push. Lost/missing status retires the SSH connection without a replay or success. *(A29, A107)* +### Links and copy in the terminal + +54. Print a URL long enough to wrap over several rows, e.g. + `printf 'https://example.com/%s/end\n' "$(head -c 300 /dev/zero | tr '\0' a)"` in a shell, and + ask a Claude session to print one: tap the URL on its first, a middle and its last row. Each time + a bar names the host and shows two lines of the URL, and nothing reached the pane (no click in + Claude, no soft keyboard). The bar's All shows the WHOLE URL, scrolling inside the bar when it is + long: the printf one starts `https://example.com/` and ends in `/end`, wherever the tap was; Less + shortens it again. Open opens the browser on the whole URL; Copy puts it on the clipboard ("Copied + the link"); × closes the bar. A tap or a drag on the bar's text, away from its buttons, changes + nothing: no click or scroll in the pane, and the bar keeps its link even with another link under + that spot. A tap on plain text, and a swipe that starts on a URL, behave as before. *(A32)* +55. An OSC 8 link: `printf '\033]8;;https://example.com/osc8\033\\label\033]8;;\033\\\n'` in the + pane, then tap "label": the bar offers example.com and opens `https://example.com/osc8`. The same + with `file:///etc/passwd` in place of the URL offers nothing. With a mouse connected, a click on a + link in a session that is not under tmux (a Windows computer's), where the pane does not report + the mouse, offers it too. *(A32)* +56. The key row's Copy chip opens a sheet of the screen's lines (with a session that is not under tmux, + some scrollback too) and, above them, its links with Open and Copy. Tap lines to select them, + long-press one to select the range from the last line tapped; Copy puts them on the clipboard + ("Copied N lines"), Share opens the system share sheet, and Back or × closes the sheet. The sheet + opens on the line at the top of the screen. A tap on the sheet's title, its instructions or its + "No lines selected" line does nothing: no soft keyboard comes up, and nothing reaches the pane + under the sheet (after closing it, the pane shows no click or scroll there). Separately, Find + searches text emitted before attachment in the computer's retained history on both routes: + literal case-sensitive query, line numbers, caps, Previous/Next and copied matches. A new query, + replaced stream or background closes/discards stale results; Copy-sheet search remains local. + *(A32, A98, A100)* + +### Notification actions + +57. An approval notification (a held Claude permission, through the relay and again on the same + network) shows Approve and Deny. With the phone unlocked, Approve answers it within a few seconds: + the notification reads "Approving…", then "Approved." and goes away by itself, and the desktop's + prompt is answered. Deny the same way. With the phone locked: on Android 12 or later the tap asks + for the unlock first; on Android 8 to 11 the notification says to unlock and nothing is sent. On + Android 11 or lower a short "Sending your answer…" notification shows while an answer is sent. + *(A25)* +58. A single-select question with up to three options shows "Option 1", "Option 2", … (the options' + text with "Show details in notifications" on, cut to fit); a tap answers it in the live session. + One with four or more options, and a multi-select one, shows Open, which opens the session. + *(A25, A57)* +59. Answer from a notification something already answered on the desktop: "Already handled.". Answer a + held approval after its hold expired: the notification says it timed out, and its tap opens the + session. Pair a computer with remote access off, let it raise an approval's notification on the + same network, then leave that network (mobile data) and tap Approve: the notification reads + "Not sent: couldn't reach …", and nothing appears on the desktop; with remote access on (and the + phone approved for it at the scan), the same tap answers it through the relay. Tapping Approve twice quickly sends one answer, and Approve on + two notifications of one computer a few seconds apart answers both. *(A25, A05, A06)* + +### All computers + +60. Pair two computers. Each row of the computers list shows "N need you" once that computer has + been listed (opened, or checked in the background), and nothing before. "All computers" at the + top of the list opens one screen: its Inbox lists both computers' cards newest first, each + naming its computer; Approve, Answer and Open on a card act on that card's computer (try one on + each, through the relay and on the same network), and its Usage tab shows one section per + computer that reports usage, with the computer's name and when it was measured. Away from the + network of a computer that has never approved this phone through the relay, opening the screen + shows that computer's approval code under its name; deny it there, and opening the screen again + shows the refusal with a Try again and puts no dialog on that desktop until Try again is tapped. + With the screen on its Inbox tab, raise a permission prompt on one desktop and tap Refresh: its + card appears on top, and no notification follows, then or later. Open a terminal from a card and + come back, and kill the process in the background: the screen and its tab come back. *(A55, A05, + A22, A43, A73)* + +### Dictation + +61. In a terminal, tap the mic beside Send. The first time, Android asks for the microphone: deny it, + and the input bar says how to allow it; tap again and allow it. Speak a sentence: the words show in + the draft as you speak, the final words replace them when you stop, and nothing reaches the pane + until you tap Send. With text already typed, the dictation is added after it with one space. Type + right after a dictation, with the keyboard still up: the text goes at the end, after the dictated + words, both into an empty draft and after text typed before with the cursor moved into it. Tap + the mic while speaking: it stops and fills the draft. Type while it listens: the dictation stops + and later words do not come back. Move the cursor while it listens: it keeps listening. Start one + and say nothing: one line says so ("No speech heard." or "Didn't catch that."), with an OK. In + airplane mode, on a phone without offline speech recognition, one line says it could not reach + the network. Send the app to the background while it listens: the microphone indicator (Android + 12 and later) goes off. On a phone with no speech recognition service (no Google app, say) the mic + is not shown, and the keyboard's own voice typing still fills the field. In Settings → Dictation, + choose a language/region, return to the terminal and check its recognition and draft-only behavior. + Test System default and a service-unavailable language, and cancel/background during dictation. + These A114 variants are Pending and inherit no older recognizer pass. *(A59, A114)* + +### A computer a desktop drives over SSH + +62. Pair with a Linux computer that runs nodeterm and that another computer's nodeterm also uses as an + SSH project host. On the same network, the Sessions tab lists that other desktop's project after + this computer's own ones, marked "run here over SSH", with its sessions and, while that desktop is + connected, their states; quit that desktop and within about two minutes those states read + Unknown, while the sessions stay listed. Open one of its sessions and type; answer one of its + approvals from the Inbox; end one of its sessions: the other desktop shows it ended. Its canvas New + session and unowned Board/Wake/Refresh/Rename say they belong to the other computer; direct SSH + Git works only within that host's listed admitted folder. A node of + the paired desktop's own SSH projects still opens through the relay. *(A27, A09, A108)* + +### A computer added by its SSH address + +63. On a Linux computer with a Server Edition installed (`install-server.sh`) and no desktop app, + tap "Add SSH server" on the computers list, fill in address, port and user, and tap Connect + before adding the key: it says the computer did not accept the phone's key, and nothing is + added. Share the key line to yourself, run the screen's command on the computer as that user (in + bash, then once more in fish or zsh: the key is in `~/.ssh/authorized_keys` once, the file `600` + and `~/.ssh` `700`), and tap Connect: the computer is added and the screen shows a `SHA256:` + fingerprint that matches one line of the screen's `ssh-keygen` command on the computer. Open it: + the Sessions tab lists the Server Edition's projects, the host screen shows its version, and a + session opens and takes keys. Current Server Board writes for its local owned projects and + direct SSH Git work without a relay; Server SSH-project references remain refused. Canvas New and + Wake/Refresh/Rename remain unavailable, as does a cold session that is not running; Settings → How + to reach each computer offers no choice for it. *(A27, A108)* +64. Add a Linux dev host that another computer's nodeterm drives over SSH (no nodeterm of its own) by + its address: its projects and sessions are listed as in item 62, and approvals are answered from + the Inbox. Open one of its finished sessions on the phone: within about 15 s the other computer's + nodeterm clears that session's unread dot. Then reinstall its SSH host keys (or point the address + at another machine): the phone refuses it, saying to forget it and add it again. Forget it: the + dialog names the `nodeterm-android` line to remove on the computer, and the computer leaves the + list. Adding an address that is already in the list (paired or added) is refused with its name. + *(A27, A49)* + +## Known gaps + +**Closed SSH/tmux exit display (`A133`, published source repair; bounded physical checks pass).** The +[source checkpoint](#closed-sshtmux-exit-display-source-checkpoint-2026-10-07-a133) retains only +the last visible pane at a current known SSH/tmux EOF, with inert Copy/links and no Live/input. +Full 1033/110/app/TypeScript gates and exact-head five-job/ten-step CI pass at `ef4caec2`; +beta 21/code 22 is installed. One same-page closed-screen/Copy-sheet/link-offer/font-redraw +physical case passes. The later exact final-marker/Unicode clipboard paste and post-font OSC8/plain +link-offer pilot also passes. The later [normal browser/Share pilot](#fresh-fedora-output-and-native-desktop-readiness-2026-10-08) +is bounded to observed navigation/chooser preview; wider TUI/lifecycle checks remain unverified. +Checklist item 25 covers this retention without promoting the 64-row ledger. + +**Native history scrolling (`A129`, published source; acceptance pending).** Published +`1add0408` adds negotiated inert history and mouse routing through its captured backend. The +[implementation checkpoint](#native-history-scrolling) keeps the original component failure +separate from the passing source gates and CI. At that earlier checkpoint, beta 18/code 19 was +ready and not installed, and installed beta 17 had not acquired this change. The current beta-19 +checkpoint verifies bounded known-tmux/direct-SSH cases; inert native/session-host app history and +physical ConPTY acceptance remain unverified. + +**Held Live-button touch (`A130`, focused source fix; acceptance pending).** Touch-down now +stops old momentum and native pending movement without closing history or advancing its +display epoch. The [finding](android-audit-2026-09.md#a130) retains the `1add0408` failure +(20 additional requests / 23 notches over 512 ms) separately from the passing 48/7 +control/restored checks and three semantic assertion mutants. Final gates/CI pass on published +`5bda2c32`, and beta 19/code 20 is installed with measured update preservation. Actual +WebView/physical behavior acceptance remains pending. +Device checklist item 20 includes this held/moved/cancelled Live-touch and late-page check; the +original 64-row physical ledger remains unchanged. + +A125 is source-fixed in `415dae9b`; A126 is committed in `5b7286b3`. +The eight controlled Linux Desktop reconnect cases, 294 affected tests, incremental TypeScript, +15 isolated assertion mutants and exact-source forced950/98/app gates pass. The `11e22453` +stale-input baseline, unequal-grid refusals and first `415dae9b` control's unclassified exits/ +18-byte DA diagnosis retain separate provenance. The additional two-host soft/none case at +`59e4c93e` verifies one quiet park/adopt cycle and inactive global-card recovery with active A +unchanged; see [the bounded two-host follow-up](#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06). These disposable QA-host results add no APK or regular +Desktop installation. Remaining release acceptance needs a reachable intended phone for the +physical lifecycle/outage, answer/notification/permission and live-provider checks. Wider parking, +Server/cross-window response ownership, repeated outage/backoff, GPU/display and other-platform +coverage remain separate from those device and hosted-backend obligations. + +Immediate Android push (`A25`) and fresh-different-desktop relay recovery (`A93`) need the hosted +backend maintainers. The user has no service repository to supply. Same-owned-desktop paired-update +recovery does not establish recovery on a fresh different identity; no backend fix is claimed. + +The [native custom-wheel checks](#native-custom-wheel-checks-2026-10-06) verify two copy-mode tables, +chunked distance and reversals over actual SSH/tmux. The broader phone custom-binding/lifecycle/FPS +matrix remains open; those native results do not promote the paused device checklist. + +- **Remaining native matrix.** Linux DOM Desktop/Server and GPU-enabled Desktop link checks pass + (four hardware Canvas WebGL2 plus four intentional DOM Modal cases). Native File-menu Quit, + outside-terminal Ctrl+Q and one real tmux Canvas pilot also pass. A122 fixes focused-terminal + native Quit; all four Linux native cases pass. Later tmux8/SSH8 and individual GPU loss/restore + checks pass, including A123 native retirement control/mutant/restored. A genuine Server retirement + pilot, derived Desktop compositor cell comparison and single Desktop SSH reconnect pilot also pass. + Repeated/page-wide GPU pressure, + full-frame compositor/display output, SSH reconnect beyond the accepted eight-case and two-host + global-card checks, and macOS/Windows remain unverified. Synthetic installed- + Fedora-PAM setup passes; real-user + policy and Android setup/lifecycle still need their own checks. + See [the latest native receipt](#native-setup-timing-installed-pam-and-remaining-desktop-checks-2026-10-05). + +- **A95 is fixed and physically verified in the owned production fixture.** Commit `ec12ea9a` + fixes the core Board announcement and actual Desktop preload subscription. Actual beta 10 phone + move/remove/re-add/create-label actions now update the mounted desktop without reload; item 36 + passes. The held071 stale-Board failure remains recorded separately. No regular-user-desktop + deployment or Android APK change is claimed; other device checks remain open. + +- **Fresh-desktop relay pairing can finish without a relay credential (A93, open).** The + same Pixel identity pairs with remote access on, but the actual mint refuses HTTP 403 + reauth_required in a bounded production retry. The old fixture's phone-side token was forgotten + and the desktop identity changed. Ordinary complete-original-profile restart and re-pair + now succeeds with unchanged identity and relay credentials, including saved beta-10 reconnect. + This does not resolve fresh-different-desktop refusal or establish the full backend policy. + No backend source or user credentials were copied. iOS recovery needs verification for @eneskirca. Historical beta-6 relay proof remains valid; pairing/relay device checks remain open. + +- **Completed empty SSH listings looked like loading in beta 9 (A94).** Source fix + 3c217cba timestamps the authoritative empty answer while preserving the A91 route/error + behavior. Five Kotlin methods/eight mutants, full 689-test protocol and beta-10 delivery pass; + both installed sole-End cycles show the completed empty label with no Loading/row/group + while SSH/error remain; Home recreation opens an actual bash pane between them. Item 32 stays Partial. + +- **The desktop wrapped-link follow-up is fixed in `127b6b28` (`A92`).** The + shared Desktop/Server renderer's capped paragraph could omit the hovered row after more than + 32 continuing rows. Reserving one upward-budget row fixes complete URL lookup. Focused Vitest + 24/24, all affected link/dialect tests 54/54, full TypeScript check and three isolated mutants + pass; all eight native Linux Desktop UI cases now pass at `01b4a2f5` with A121's leave fix. + Separate Linux Server checks pass at `dcdf664a`. The later GPU-enabled eight-case matrix passes + (Canvas4 hardware / Modal4 DOM). Those earlier runs did not verify GPU recovery; later individual +loss/restore pilots are recorded in the final follow-up. macOS remains unverified. Android already + has the `A32` correction. + No host RPC/blob/pairing/mirror/SSH-file change or iOS adoption is owed. Beta 10/code 11 + remains last installed; the device ledger is unchanged and no new phone evidence is claimed. + +The current `/v1/relay/join` contract is verified against the live hosted backend: the Pixel's +encrypted paste pairing and forced relay-only browse succeed against production desktop source +`58a202be`. The backend is a separate repo; cellular relay, SAS denial/revoke, interruptions and +the wider relay action matrix remain device checks. + +- **A pairing that recorded no relay key still asks once on the relay** (audit `A07-late`). The + desktop approves a late-adopting phone by the box key its pairing recorded from the sealed `/pair` + body. A pairing made by a phone that does not send `boxPublicKey` (the iOS app, until it adopts the + field), or by a desktop older than `A07`, recorded none, so that phone's first relay connect after + a late adoption still shows the first SAS dialog. A118 can repair an eligible legacy association + after approval using the phone's retained SSH identity, so later exact revocation works. Missing + identity/key attribution and relay-only pairings still need re-pairing. iOS can adopt `boxPublicKey` + and `relayApproved` unchanged, plus the new proof verbs/encoding for eligible legacy records. +- **Push.** No FCM leg exists in the backend; the app polls (see android/README.md). The backend's + `/v1/push/*` fan-out is APNs-only, so nothing wakes the app when an agent needs you, and there is no + equivalent of iOS's Live Activities (an ongoing notification would need FCM or a foreground + service). The desktop's phone-push switches (Needs you, Done, hold alerts while at the computer, the + per-computer mute) gate only its own APNs send and are not in the mirror's `settings`, so no phone + can read them; Android's per-kind control is its two notification channels. What the app does have + (`A25`): the notifications it posts itself carry Approve / Deny and a question's options, and their + tap opens the session. +- **A computer added by its SSH address is SSH only, and has no push** (audit `A27`, part b). "Add + SSH server" reaches a headless Server Edition or a dev host the phone reaches only over SSH, but + only where the phone can open an SSH connection to it (the same network, or a VPN): there is no + relay leg for it, ever, and no "from anywhere". Direct Git works on admitted local/driven folders + without an actions service. A current selected-profile service supplies owned Board writes on + Desktop/Server and delivery-only Wake/Refresh/Rename on Desktop; Server has no node nudges. + Missing service capabilities have no relay fallback on a manually added host, and + A111 supports canvas-registered New on a current enabled local POSIX tmux profile. Missing + creation capabilities still need the relay; phone-owned plain SSH New remains separate. + It gets no push either: the grant an iOS phone drops in + such a host's `~/.nodeterm/push-grants` and the backend's `/v1/push` fan-out are APNs-only (see + Push), and Android drops none, so the phone polls it like any other computer; docs/SERVER.md's + "full push / Live-Activity coverage" is iOS's. A120 adds optional one-time password setup after human + fingerprint confirmation. Keyboard-interactive/MFA, the Server Edition's `install-server.sh` + one-liner offered per connection, and Windows remain unsupported (the + browse is POSIX `sh` + tmux, as for a paired computer). The host key is trust on first use, as for + a paired computer, but with no pairing LAN behind the first connect: compare the fingerprint the + screen shows. A119 adds an explicit saved profile folder for a Server Edition's custom `--data-dir`; + a slice's freshness compares the phone's clock with the driving desktop's; on a + computer that runs its own nodeterm AND is driven, launch settings come from its own mirror (a + driven session's wake line uses its permission mode); a project whose sessions all start outside + its folder (a worktree beside it) has no file found, so it is named by its id from its slice, and + its plain terminals are listed under "Other sessions on this computer". When no data dir is found + but another desktop's sessions are, the listing shows that desktop's projects and nothing of the + `node-terminal` sessions of a nodeterm whose data dir the phone could not find (a Server Edition + with `--data-dir` elsewhere and no explicit selection), and nothing says they are missing. +- **Read-ack ownership is locally fixed; device validation remains open** (`A82`). Both the local + `src/core/ack-sweep.ts` consumer and `sweepRemoteAcks` require ownership before reading/removing + `~/.nodeterm/acks/.seen`. Remote ownership combines all connected projects on the host. + Retained local files are retried when ownership changes, including restored unresolved Done + cards. Android's actual producer/consumer interop is covered; the multi-desktop device case and + iOS verification remain pending. Held approvals continue to use each hook's own `.answer` file. +- **Explicit plain SSH creation has focused Pixel proof (`A90`).** It uses `nodeterm-phone` and a + separate Phone terminals group; it does not change canvas registration or managed-agent + creation. Home/project/custom creation, cwd/input/history, restart/reconnect/update survival + and exact owned End pass on beta 8/9 without changing desktop sessions/files. Item 32 is Partial; + relay plain-shell creation/input/End now pass; managed and cellular creation remain pending; A91's empty-host variant passes on Oct4. + +- **Empty-host stale rows are fixed in installed beta 9 (`A91`).** The branch clears them + only on authoritative `NothingFoundException`, retaining cached rows on uncertain failures and + keeping the SSH route/error intact. Host regressions/mutations, beta-9 delivery and the Oct4 + physical otherwise-empty-host last-End/row-and-group removal/connected SSH/New-terminal flow + pass. That second Home shell later survives beta 10's update and is ended in the full A91/A94 + empty-screen/Home recreation/second-End cycle; final fixture/service cleanup is not claimed. Broader checks + resumed on Oct4; item 32 stays Partial because managed and cellular creation remain open; relay plain-shell creation/input/End pass. + +- **Direct SSH is POSIX-only (`A108`).** Current Desktop/Server instances advertise their + private typed SSH actions service for Board writes on the selected profile. Desktop additionally + accepts Wake/Refresh/Rename nudges; Server has no renderer and advertises no node nudges. + Unknown claim ownership fails closed; a crash inside its short startup/cleanup transition + requires operator recovery before the SSH service can be advertised again. + The service uses the actual WorkspaceStore save queue and normal change broadcasts, preserving + mounted edits. Old/unavailable services may use an allowed relay before submission; a submitted + unanswered mutation never replays or switches transport. A Desktop can serve its own SSH-project + metadata; another desktop's driven project is refused without selected-profile ownership. + A111 adds genuine host-owned create/launch/register for open local folder projects using enabled + POSIX tmux. Windows/non-tmux and third-machine creation remain unsupported. Ordinary attach + never creates a missing canvas node. The separate phone-owned plain SSH terminal remains. +- **Direct SSH source control is implemented (`A107`); its physical matrix remains + open.** The eight existing typed verbs run Git on listed local/driven folders of that computer, + with physical cwd/repository-root jails, bounded output and honest confirmed/uncertain write + outcomes. Third-machine projects remain unavailable. A second push is allowed only for the + exact native exit-128 missing-upstream diagnosis matching the current branch before dispatch. + Branch switch, discard, init, publish, per-commit file lists, older-than-50 history and merge + resolution remain outside the exposed contract; the phone still cannot edit or stage unmerged + conflict resolutions through this screen. + +- **Direct-SSH coattachment is source-fixed (A13, `ed2965be`).** Updated hosts preserve external phone clients in either attachment order and remounts; only verified app-owned prior viewers are retired. Real tmux control-client checks and eight actual Linux native checks pass; physical SSH/phone and macOS/Windows runtime checks remain pending. Android still reattaches when an older host detaches it. +- **Safe remembered rules and complete question answers replace the old fixed gaps (`A105`, + `A106`; prior `A56`, `A57`).** Always allow confirms only exact eligible original addRules + scopes; it cannot mean guessed option 2 or a permission-mode switch. Held questions expose every + question and submit all selected labels through the documented hook contract, preserving full + input. Older phones see Open on held v2 cards; older hosts, unsupported/free-text cases and + expired/replaced tickets never fall back to digits. Unheld legacy multi-select remains read-only + with Open session. Bounded beta-19 phone rule and single/multi question cases pass real CLI + application at `e06b5547`; rule persistence after CLI restart and the wider matrix remain open. + @eneskirca must adopt the additive mirror/verb/v2 SSH contract in iOS together. +- **Legacy unheld multi-select questions remain Open session.** Complete held questions use + the v2 hook contract described above; unsupported/free-text schemas open the session. +- **Sleeping offscreen relay wake is implemented (`A104`).** The desktop resolves the saved + node/project and owning live/released pane even outside the mounted canvas, without switching + tabs or creating a shell. It preserves explicit Pause, recorded exit proof and exact process/ + generation guards, and never retries an uncertain write. Unsupported older hosts refuse safely; + physical inactive/closed-project wake and ownership/lifecycle variants remain to verify. +- **Trusted project env/shell and per-agent policy are carried on cold relay launches (`A102`, + `A103`).** Saved host owner/account/agent facts win over phone hints; warm panes are left intact. + The phone now uses the measured Claude/Codex/Gemini/Grok dialects for launch, cold resume and + Sleeping wake. Codex's host vocabulary and Claude's own auto gate determine emitted flags; + unsupported modes keep the CLI default. No physical/live agent policy matrix is claimed. +- **The SSH pin is anchored only by a current desktop, and the LAN address is refreshed only over the + relay** (audit `A49`/`A74`). A desktop with `A49-anchor` names its SSH host keys in the sealed `/pair` + answer and the first connect must present one of them; an older desktop, one that could not read + its keys, a computer paired before this build and one added by its SSH address still pin on first + use (on the pairing LAN, right after the QR, so normally the real computer), where a server that + accepts any key could become the pin. An sshd that serves a key `ssh-host-keys.ts` cannot see is + refused over SSH, as a key the computer did not report: a relative `HostKey` path, a key with no `.pub` + beside it (sshd needs only the private key), and a `HostKey` named only in a config file the + desktop's user cannot read (some distributions install `sshd_config` and its drop-ins readable by + root only; `/etc/ssh/ssh_host_*_key.pub` are still read there). Pairing again does not help: it + re-reads the same files and names the same keys, and the phone has no way to accept the key it was + refused (forgetting the computer and pairing again sets the same anchors). In Auto, with a relay + leg, the phone then uses the relay and keeps its warning up at every connect; with "Only on my + network", or a pairing with no relay leg, the computer is unreachable over SSH. The ways out: + choose "Only through the relay" for it; or make the key visible to nodeterm on the computer (a + readable `.pub` beside a key that is an `/etc/ssh/ssh_host_*_key` or a `HostKey` in a readable + config) and pair again, or let a relay connect report it; or forget it and add it by its SSH + address, which trusts on first use and has no relay or push (its screen's `ssh-keygen` command + reads only `/etc/ssh`, so compare the fingerprint with `ssh-keyscan localhost | ssh-keygen -lf -` + on the computer instead). The iOS app does not read the field yet. The relay refresh + (`A74-refresh`) does not take the reader's answer as the truth about the pin: it drops one only for a + key the SSH leg was refused that the reader also names, so a pin that works (one trusted on first use + with an older desktop, on a computer whose sshd serves a key the reader misses) is kept. The + refreshed address now follows the same saved Desktop Settings → Phone adapter as the QR (A116). + Automatic prefers a physical LAN adapter on POSIX; select a VPN or another adapter when needed. + DHCP follows that adapter's current IPv4. A missing selected adapter supplies no replacement + dial address; current address enumeration does not prove phone reachability. The refresh needs + a relay connection, so a phone that only ever uses + "Only on my network" keeps the pairing's address and keys, and the iOS app does not read `lan` yet. +- **Private beta device validation pending** (audit `A50`). AGP marks every debug build + debuggable: anyone with adb access to the unlocked phone while USB + debugging is on can read the app's files (`run-as`) and attach a debugger to the running app, whose + code can use the Keystore key those files are sealed under. That is the phone's pairing + credentials: the SSH key its computers accept, the relay box secret and the relay device token. + android/README.md says so under Security. A private beta now has a local signing/verification + tool and opt-in versioned unsigned CI inputs, documented in the README; its key and signed APK + stay off Actions. The first private minified APK is installed on the intended Pixel and basic + SSH listing/input works. The code-3 update fixes sizing/history (`A85`) and preserves identities; + code-4 scroll mitigations pass protocol/type-check and local AGP/R8/signing checks, and its + in-place update verifies identity persistence, reduced drag gain, reversal and Esc cancellation. + The user reports that beta 3 still lags and moves too little; `A87` corrects automatic-report + cancellation and restores responsive gesture gain. Beta-4 delivery and controlled movement pass + in the verification record above. The user reports more movement but no momentum on finger lift; + the bounded-fling code-6 beta builds/signs/updates and passes 658 protocol tests, but actual + continuous swiping still stops. `A89` fixes the detached target; code-7 build/sign/update and + 658 protocol tests pass. Code 7 reopens SSH with its retained key/pin and verifies controlled + continuous drag/coast/Esc. A held touch stops coast at a stable 56×25 viewport; earlier + IME-resizing touch checks were inconclusive. The user confirms normal drag/coast both work. + The requirement-audited real Pixel QA passes ten complete items (1/18/19/21/22/24/36/38/39/51), plus + partial copy/link and SSH recovery checks. Encrypted paste pairing, live relay browse/terminal input and + shipped-hook Approve/Deny/expiry and mounted relay question/copy-mode answers work; the hook + producer/application are synthetic, with no live Claude execution. Offscreen/released/direct-SSH/ + target-guard device variants remain open. + The user also confirms beta-6/code-7 connection and smooth scrolling over cellular WireGuard + with Wi-Fi off on regular manual SSH; cellular hosted relay is untested. + QR/cellular-relay/SAS/worker, real outage, + reversal/remaining lifecycle, FPS/custom bindings and the full device pass remain open. + The desktop's Android link continues to open the `android/` source folder and both + phone surfaces label it "nodeterm for Android (build from source)" (`ANDROID_APP_LABEL` in + `src/renderer/lib/links.ts`, audit `A66`); drop that label when the link points at a release. +- **Dictation is the phone's own recognizer, not Whisper** (audit `A59`). The input bar's mic uses + Android's `SpeechRecognizer` (see "What is verified, and how"), which on most phones is Google's + service and may send the audio to its servers. iOS and the desktop transcribe on the device with + Whisper; running whisper.cpp on the phone would be a native build and model downloads of its own. + The Cloud engine iOS and the desktop share (`/v1/transcribe`, multipart WAV and a locale) does not + exist on the backend yet (`src/core/speech/cloud-speech.ts` maps its 404 to "not available yet"), + so there is nothing for Android to send that request to either. Settings → Dictation now offers + a searchable language/region choice (A114); System default leaves the recognizer's language unset. + The locale catalogue does not prove the phone's speech service has that model installed, and + unavailable/unsupported errors stay visible. Physical language checks remain pending. The phone + keyboard's own voice typing (Gboard's mic, say) also works in the input bar, mic button or not. +- **Instrumented UI tests** and a store listing do not exist yet. + +## A113 + +**A legacy pairing can report a successful revoke without proof that relay access was removed (2026-10-04).** + +Fixed in `349b791a`. Desktop revocation now reports `unconfirmed` for a saved pairing with no +recorded relay key or no available revoker, and `retained` when another saved pairing still +authorizes that exact key. Settings shows these outcomes separately from local SSH-key removal +and Pro expiry. An absent relay outcome from an older main process also remains unconfirmed; +the confirmation dialog no longer promises removal of every connection. Confirmed last-key +unpin/session closure remains required for the clean relay receipt. Seven affected main/renderer +suites pass 111 tests; eleven isolated assertion mutants are caught with passing control/restored +runs. The source-bound mutation fixture uses a temporary-home service boundary and mounted React. +These additive local Desktop IPC outcomes do not change the phone wire contract. Full legacy +identity association/migration and physical relay revocation remain open; no unrelated approved +key is guessed or removed. + +## A114 + +**Android dictation has no language choice (A59 follow-up, 2026-10-04).** + +Fixed in `6b989e59`. Settings offers a searchable language/region picker using human-readable +locale names and an explicit System default. The phone-wide preference is separate from host +identities and is frozen when a dictation starts. System default omits the native language extra; +a chosen language sends its canonical BCP47 tag. Partial/final results still fill only the draft, +and microphone, cancellation and lifecycle rules remain unchanged. The bounded catalogue lists +locales, not installed speech models: native unsupported/unavailable errors stay visible. +Three focused protocol classes pass 39 methods, offline app compilation passes, and eighteen +isolated compiled mutants fail assertions with passing control/restored runs. Native intent/UI +wiring is covered by source guards; the cached Kotlin/Compose compilation is separate evidence, +not a recognizer/device pass. Physical language availability, recognition and layout checks remain +pending. No host wire contract changes. + +## A115 + +**Re-pairing leaves local host preferences and lets retired connections publish late (2026-10-04).** + +Fixed in `0a71c028`. Replacing a paired record retires its route, relay approval, +managed-creation checkpoint and old token while preserving unrelated computers and same-ID preference +updates. Forget and re-pair retire the exact old connection objects; a shared session-admission +barrier covers local record/token publication, including incoming IDs with no previous key match. +No network await runs under that barrier. A failed local secret write retains the record, while its +old socket remains retired. This is a local monitor fence, not a crash-safe Keystore/preferences +transaction. + +Owner-bound lifetime leases cancel pending dials and prevent late pin, LAN, relay-token, approval, +notification or checkpoint publication into a successor, even when it reuses the ID and key. +Normal disconnect still permits reconnect and retains an uncertain creation receipt; permanent +retirement never reconnects. Both primary and side relay results are retained before cancellable +handoff and closed on cancellation. Quiet notification reach uses the exact session without a +reverse manager lookup. Closing local polling does not revoke an existing Desktop SAS consent. + +Private controls/restored runs execute 177 repository methods and 29 actual +production HostStore adapter cases against in-memory Android interfaces. 73 isolated variants +fail assertions. One additional redundant retired-guard experiment survives equivalently because +retirement also invalidates every admitted generation; it is recorded separately and is not counted +as caught. Native wiring remains source-guard evidence, with app compilation checked separately. +No phone, live transport, physical durability or full legacy Desktop identity migration pass is +claimed. No host wire contract changes. + +## A116 + +**Pairing can advertise a Docker/VPN address the phone cannot reach (A49/A74 follow-up, 2026-10-04).** + +Fixed in `a6b3e88b`. Desktop Settings → Phone lists current IPv4 adapters and an Automatic choice. +The saved selection is an adapter name, so each new QR and authenticated relay LAN report follows +its current address across DHCP. An unavailable explicit adapter prevents a new QR and omits the +report's dial address; it never silently substitutes another adapter. Automatic prefers a physical +adapter on POSIX, retains Windows' validated current route hint, and permits a virtual-only fallback. +The list describes current local addresses, not proof that the phone can reach them. Windows remains +relay-only; browser Server pairing/network controls deliberately remain unsupported. + +Both Settings and quick pairing acknowledge pending settings writes before starting. Changing the +choice hides/stops the old QR first, offers an explicit failed-save retry, and cannot restart after +its visible row is hidden. Real global-search rows work too; hiding that row retires only its own +listener, including while its parent hook remains mounted. Old replies/events cannot stop or update +a newer pairing view. The legacy exported picker helpers also use the shared policy. + +Isolated Desktop controls/restored runs pass 156 methods; one unrelated pre-existing worker-spawn +guard is explicitly filtered in that private sandbox and remains part of the full merged gate. +Root's actual local HTTP-listener controls/restored pass all six methods. Five producer→Android +methods call the real policy, QR builder and LAN reporter with a fake OS table/public-key directory, +then the existing Android parser and LAN-refresh policy. They prove paired-host identity/pin +preservation, not Keystore or approval publication. The three final proof groups catch 56 assertion +mutants (32 Desktop, six real HTTP, eighteen interop); preliminary receipts remain separate. +The existing wire shape is unchanged and the Android client needs no parser change. iOS @eneskirca +should adopt the existing authenticated `lan` field; it now reflects the saved Desktop adapter. +Physical QR/VPN/DHCP, search UI and multiadapter checks remain pending. No external message sent. + +## Direct SSH coattachment follow-up (A13, 2026-10-04) + +Fixed in `ed2965be` after the beta-14 APK checkpoint. A current Desktop app attaches beside +external SSH/relay clients, then replaces only a live app painter attested by an exact PID/birth +receipt in the same private userData profile and tmux socket/session. Both old and incoming client +identities are rechecked in the tmux command queue before an exact-client detach. A failed or +departed new attach cannot evict the old viewer. Unknown, legacy unmarked and other-profile clients +survive; missing process attestation preserves viewers. Existing per-manager joins and grid policy +remain. Exit/quit releases the exact receipt; only proven stale receipts are pruned within bounds. + +Private controls/restored pass 122 focused tests and full TypeScript checking. Twenty-four distinct +isolated mutations fail assertions; three critical variants are additionally caught against the +actual tmux server. Seven real private-socket tmux control-client methods pass without skips on +tmux 3.7c: both phone/app attachment orders, unequal native pane grids, remounts, foreign/legacy +clients and stale-client races. Control-client height formatting is empty on this host, so grid +evidence reads the native pane and checks phone-only restoration. + +An additional Linux smoke at source `32d412b6` passes eight checks using cached Electron 42.11.3, +Node 24.19.0, actual node-pty and tmux 3.7c. It exercises the production `PtyManager` and painter +tracker in a private home/tmp/run/PID/network namespace: external/app attachment in both orders, +same-manager joins, selective app remount takeover with exact shell output retained, and normal +quit receipt cleanup while the external client and shell survive. Observed native pane grids +return to 56×48 after the 100×40 app replacement leaves. The in-memory `CorePlatform` adapter is +an explicit IPC boundary; no actual renderer, SSH handshake, relay, Pixel, macOS or Windows runtime +pass is claimed. The cached native binding, Electron binary, all 96 bundled source inputs and +external node-pty wrappers are hash-bound and unchanged across the final run. + +The private painter receipts are internal app state, outside the phone's named workspace/status/ +project files; no host-service verb, payload, mirror or client wire shape changes. Android keeps +its exit-zero reattach fallback for older hosts. The same host policy preserves iOS SSH clients; +@eneskirca should verify physical coattachment. Prepared beta 14/code 15's Android client remains +unchanged and uninstalled; host-side verification uses the later source. The device ledger stays +10 Pass / 22 Partial / 32 Pending, and phone testing remains paused. + +## A117 + +**Relative private HostKey declarations can be opened by a configuration Include (2026-10-04).** + +A relative `HostKey` is still excluded from SSH anchors because discovery does not know the +daemon's working directory. Its normalized private basename now also excludes lexical and +canonical Include candidates before they are opened. Explicit public `.pub` declarations remain +readable, and relative declarations share the existing bounded declaration budget. This is a +conservative exclusion by name; it does not infer relative paths or discover unknown aliases. + +All 22 host-reader methods pass on this Linux host, including the existing real FIFO race. +Seven isolated reader mutations fail assertions with passing controls/restored runs. The real +pairing-service → Android regression seals only a disposable server's permitted key, preserves +the anchors in the phone record, accepts that real SSH server and refuses a second real server. +The original source and removal of the canonical exclusion both fail that regression; restored +source passes. Private-file inputs are synthetic config sentinels, never actual private keys. +Proof is retained in `.nodeterm/android-relative-hostkey-2026-10-04/`. + +The sealed field and Android production code are unchanged; the actual interop fixture is updated +with the producer. iOS @eneskirca should verify the existing anchor handling against the current +host. Prepared beta 14 remains valid client source and is not installed. Physical checks remain +pending; phone testing is paused and the ledger stays 10 Pass / 22 Partial / 32 Pending. + +## A118 + +**Eligible older pairings cannot associate their existing relay key with a saved device (2026-10-04).** + +The standing phone host now offers `pairing.relayKeyChallengeV1 {pairingId}` and +`pairing.relayKeyProofV1 {challengeId, signatureB64}` only after the normal relay approval gate. +A legacy pairing is eligible only when its exact host-minted UUID has one unambiguous, canonical +Ed25519 public key under the host's existing `nodeterm-ios-` authorized_keys attribution. +Missing, option-bearing, duplicate, malformed, replaced or unsafe files refuse proof; relative +identity guesses and device names are never used. + +Each connection holds at most one ephemeral challenge and one in-flight inspection. It binds +version, challenge UUID, pairing UUID, pinned host box key, authenticated peer box key, raw SSH +public key, random nonce and expiry. All fields use the fixed newline encoding in +`src/shared/relay-pairing-proof.ts` and Android `RelayPairingProof.Challenge.signingBytes`. +Both wall and monotonic host time enforce 60 seconds. Every proof attempt consumes the challenge +before awaiting publication, including malformed signatures; another session cannot use it. + +Android attempts the optional proof only after an approved first listing, using its existing +encrypted SSH seed. It validates the captured host, peer and SSH keys before signing, checks the +current record/lifetime around awaits, and never creates an identity, changes approval or tokens, +or retries an uncertain proof. Missing identity, unsupported older hosts and ordinary optional +errors preserve browsing; cancellation propagates. A socket closed during proof cannot be handed +off as connected, and a later network close after proved approval is not reported as Deny. + +The actual host pairing queue stages a private association, then rechecks the bounded exact +registry snapshot, attributed public key, approval, connection lifetime and expiry immediately +before synchronous atomic publication. Existing same-key association is idempotent; conflicts and +deleted/replaced entries refuse. This is a same-service queue, not a cross-process transaction. +Normal revocation can then unpin/cut the exact proven last key; a sibling pairing still authorizing +that key keeps the explicit retained outcome. + +Focused controls pass 87 host and 15 Android helper methods. Forty compiled host and 25 compiled +helper mutation variants fail assertions. Seven real producer→Kotlin encrypted interop methods +cover sealed legacy pairing, actual signature verification, exact publication/revoke, distinct and +shared-key siblings, wrong SSH identity, cross-session/consumed challenges, old-host fallback, +unapproved access and the close/callback ordering. Six additional interop variants fail assertions +with passing controls/restored. The public-pin adapter, human decision, OS address and unused PTY +are explicit fixture boundaries. Private evidence: `.nodeterm/android-legacy-relay-proof-2026-10-04/`. + +This is an additive host/Android contract. iOS @eneskirca needs the same retained-key signer and +exact encoding to repair eligible legacy records; old iOS remains usable. A phone without its +retained SSH seed, its matching attributed SSH public key in the host's authorized_keys or SSH support still needs re-pairing. This proof never +bypasses first SAS approval and does not solve hosted FCM or A93. New Android source needs a later +APK than beta 14; no new installation or physical/live transport pass is claimed. Phone testing +remains paused, beta 10/code 11 is last installed, and the ledger stays 10 Pass / 22 Partial / 32 Pending. + +## Prepared private beta 15 and verification checkpoint (2026-10-04) + +`0.1.0-beta.15` / code `16` is built from clean signed source `aa902d0a52ba8ccfa0be57e073c350e90cfad5d3` and includes +A118's optional retained-SSH legacy relay identity proof. The host must also run this source for +migration; older or unprovable hosts remain browsable. A117's relative private-key Include fix and +A13's coattachment fix are included in the host source. + +Required source checks pass: 912 protocol methods / 94 suites, zero failures/errors/skips; +offline app compile; 1,919 affected desktop methods with exactly ten known macOS/Windows skips; +full TypeScript checking. All five exact-source Android jobs pass: [source CI](https://github.com/CPlusPlus17/nodeterm/actions/runs/37228985208). +A118's focused proof remains 87 host and 15 helper methods, 40 + 25 compiled assertion-caught +mutants and seven actual producer→Kotlin methods with six additional assertion-caught variants. + +The local release build uses a fresh tracked Android-only snapshot, cached AGP 8.10.1/Kotlin 2.2.0/ +Gradle 8.14.3/JDK 21 and `--offline`. R8 runtime keeps and 56 independent artifact checks pass: +SDK 36/37 signatures and 16 KB alignment, retained signer, actual version/manifest/provenance, +service-loader mappings and unchanged terminal/native assets versus beta 10. +Private APK: `.nodeterm/android-beta-15/nodeterm-android-0.1.0-beta.15.apk`. +SHA-256: `c1e707c8a18555db7e01129b709d39db342128f3c54d8103d548cc177bc9abaf`. +Signer SHA-256: `c610c3a0b637a8005b6fd858587e8cbf5260622648bc1cd97c0de0ab5f146dbd`. Private build/review evidence: +`.nodeterm/android-beta-build-15/`. The APK, signer and proof remain outside source exports. + +Live A105/A106 application is still unverified. An isolated installed Claude 2.1.289 fixture uses +the actual managed hook server/script/writer with an in-memory CorePlatform. Its fresh-context, +read-only credential/config and fixed-tool guards pass 34 static checks and the exact offline +mount/auth gate. The first attempt hid the system DNS target and timed out before tools; a +separate corrected fixture exposes only that regular resolver file read-only. It reaches the +provider, but receives authentication HTTP 401 before any tool request. No application pass is +claimed. Original profile/source/binary/resolver metadata remains unchanged; raw diagnostics stay +private and no credential refresh/reset is attempted. After the existing CLI account is usable +for inference, rerun the isolated remembered-rule/full-question checks. This gives no physical, +renderer, SSH or hosted-relay proof. + +Phone testing remains paused. Beta 10/code 11 is last confirmed installed; beta 15 is prepared +and not installed. The ledger stays **10 Pass / 22 Partial / 32 Pending**. Next device work uses +this APK and current host source, checks saved key/pin survival and the expanded checklist, +then restores rotation and removes only owned QA host state. A25/A93 still require hosted-backend +maintainers; iOS @eneskirca owes the new proof contract and earlier mirror/verb/v2-answer adoption. +No PR is opened; A68 stays deferred until requested PR preparation. + +## A118 JUnit registration follow-up (2026-10-04) + +The focused Kotlin runner exercised all 15 helper methods, but the initial Gradle/JUnit run +registered only 14: the outer-timeout coroutine test inferred an exception-valued return. +Its explicit `runBlocking` signature now lets JUnit discover it. Actual offline Gradle +controls register all 15 with no failures/errors/skips. Removing the signature compiles and +passes 14 methods, then fails the exact method-registration assertion; restoration passes 15. +The seven actual host/Android interop methods were already registered. Private evidence: +`.nodeterm/android-legacy-relay-proof-2026-10-04/junit-registration/`. + +The required local gate now checks the actual JUnit helper inventory and the outer-timeout +method explicitly. This correction changes only a protocol test and documentation after +beta 15's `aa902d0a` source; its shipped Android runtime inputs and private APK are unchanged. +The APK remains prepared and uninstalled, live Claude application remains unverified after +provider HTTP 401, and the physical ledger stays 10 Pass / 22 Partial / 32 Pending. + +## A119: explicit SSH profile folder (2026-10-04) + +Add SSH server now accepts an optional absolute **Profile folder**. Existing computers can change +**SSH profile folder** in Settings → How to reach each computer. The saved phone-local choice +selects a custom Desktop/Server data directory ahead of all automatic discovery. Blank restores +discovery. A missing or invalid explicit choice reports an error and never selects another profile. +Board, project reads and all three managed-view stages use the same captured profile. Saving a +change invalidates pending connection work and clears old displayed rows while retaining uncertain +creation receipts; it never recreates a session. SSH host pins, pairing and relay credentials remain +unchanged. Paths are quoted literally and reject relative/dot components, controls and oversized input. + +Regression coverage uses actual Server config/platform/WorkspaceStore/mirror/actions producers, +a real private MINA SSH server and isolated tmux. The fixture supplies a plain-shell planner/native +create boundary; it does not run full Server boot, installed hooks or account probes. Native storage +coverage uses actual app classes with in-memory Android adapters. Ten protocol model methods, +three actual Server/SSH methods and the complete 62-method SSH/10-method workflow suites pass. +Four compiled SSH profile mutants fail their intended assertions. Final native control/restored +runs pass 19 cases and catch the retired-session mutation; seven earlier native variants have +separate historical source bindings. Private receipts: `.nodeterm/android-ssh-setup-2026-10-04/`. Physical setup/profile switching remains unverified; +phone testing is paused and the ledger stays **10 Pass / 22 Partial / 32 Pending**. At this source checkpoint, beta 15 was +the latest prepared APK and excluded A119. The beta-16 receipt below records its later packaging. +iOS implication for @eneskirca: offer an explicit saved SSH profile choice consistently for discovery +and managed attachment. No host verb or pairing payload changed. A25/A93 and A68 remain open; +no PR is opened. + +## A120: one-time password SSH setup (2026-10-04) + +Add SSH server keeps its public-key Copy/Share/manual command and adds **Set up with a password**. +The app inspects the SSH host key without authentication. Compare its fingerprint on the computer, +then explicitly confirm **I compared it — it matches** before entering the password. Password login +rechecks that exact key, installs only the retained phone public key, closes the setup connection, +and proves a separate pinned key-only login before saving the host. A changed key, refused password, +unsafe key-file target, uncertain command or failed key verification never saves a computer. +No setup write is automatically replayed. A lost acknowledgement may leave the public key installed; +use the normal key connection or check authorized_keys on the computer before starting a new attempt. + +The password is memory-only, absent from saved state/preferences, shell text and diagnostics. Leaving +or backgrounding setup cancels owned sockets and clears its password/fingerprint approval. JVM/library +transient copies cannot all be erased. Existing keys and restrictions are preserved; commented or +trailing key-like text is not an installed declaration. Symlink, nonregular, foreign-owned and hardlinked +targets refuse. These are bounded pathname preflight checks, not descriptor-atomic protection against +concurrent same-user replacement. Keyboard-interactive/MFA, password changes and Server installation +remain unsupported. Host pins, pairing/relay credentials and ordinary key-based connections retain +existing behavior. iOS implication for @eneskirca: confirm fingerprints before password auth and verify +fresh retained-key auth before saving; no host-service verb or pairing payload changed. + +Seventeen actual protocol methods pass against a private MINA SSH server and private POSIX HOME. +They cover fingerprint/auth order, other/restricted keys, uncertainty/no replay, both-stream output +limits, own timeout versus external cancellation and socket closure. Offline app compilation passes. +Seven isolated compiled semantic variants fail assertions, with 17 passing control/restored +methods and exact integrated source hashes. Private proof: `.nodeterm/android-ssh-setup-2026-10-04/password-final/`. +The full required gate verifies actual Gradle/JUnit method registration too. This is source/fixture proof. +Actual Android setup UX, ordinary PAM/account behavior and macOS runtime checks remain pending; +the controlled native OpenSSH receipt below adds wire/exec evidence. Phone testing stays paused, +with beta 10/code 11 last installed and **10 Pass / 22 Partial / 32 Pending**. Beta 15 excluded A119/A120; +the subsequent beta-16 receipt below includes both. A25/A93 require the hosted-backend maintainers; A68 stays deferred and no PR opens. + +## Prepared private beta 16 and verification checkpoint (2026-10-04) + +`0.1.0-beta.16` / code `17` is built from clean signed source +`2723845efac75cfcf2cd0a9fdfa11d9496699641`. It includes A119's explicit saved SSH profile folder and +A120's opt-in password enrollment, along with the earlier beta-15 changes. Missing explicit profiles +never fall back to another profile; enrollment saves a host only after a separate pinned key login. +Full offline protocol checks pass **944 tests / 98 suites**, with zero failures, errors or skips. +Offline app compilation, **151 affected desktop Vitest tests / eight files** with zero skips, and +full TypeScript checking pass. Actual JUnit inventories include the new setup tests. An obsolete +inline UI behavior source pin was removed; actual SSH authentication regressions remain. +All five Android jobs pass for the APK source: +[workflow 37233114167](https://github.com/CPlusPlus17/nodeterm/actions/runs/37233114167). + +The minified release build passes offline in **54.87 seconds**. Packaging uses the retained private +signer; all **56 independent artifact checks** pass, including official SDK 36/37 signatures, +nondebuggable version/manifest, R8/runtime keeps, payload/provenance and 16-KB ZIP/native alignment. +APK SHA-256: `9c5309c8cfb225daf59f7fac70be1760fb0abdaac36cb55005b958f2186c8ce3`. +Private APK: `.nodeterm/android-beta-16/nodeterm-android-0.1.0-beta.16.apk`. +Build proof: `.nodeterm/android-beta-build-16/`; behavioral/mutation proof: +`.nodeterm/android-ssh-setup-2026-10-04/`. Earlier failed or historical runs retain their separate +receipts; they are not counted as successful merged gates. The release receipt changes only docs. + +**Prepared, not installed.** Phone testing remains explicitly paused, beta 10/code 11 is last +confirmed installed, and the device ledger stays **10 Pass / 22 Partial / 32 Pending**. +When authorized, install beta 16 as a same-signer update and verify saved identities, profile +selection/switching/no fallback, password/fingerprint/cancellation UX on actual OpenSSH, and the +remaining managed-session, relay/cellular, notification and lifecycle matrix. MINA/POSIX fixtures +and in-memory app adapters do not verify physical Android or macOS behavior. Live Claude rule and +question application remains unverified after provider HTTP 401. A25 FCM and A93 fresh-desktop +relay recovery require the hosted-backend maintainers. For iOS, @eneskirca should adopt the saved +profile/enrollment UX and review the earlier host protocol changes. No PR opened; A68 remains +deferred until a PR is requested. Every push requires its own exact-head green Android workflow. + +## Native OpenSSH enrollment verification (2026-10-04) + +A120 now has an actual **OpenSSH 10.2p1** control run in addition to its MINA regressions. +The actual protocol sources at `bac1aa7fe90416b07dd0de47ac95049e817ce8b5` were compiled privately; +**28 distinct assertions / 40 passing assertion events** verify inspection against an independently +measured public fingerprint, password login followed by fresh pinned key login and exec, +retained unrelated keys, newline/mode handling, idempotence, consumed confirmation, rejected password +and host pin, commented key text, restricted keys, symlink/nonregular targets, and closed client sockets. +These are native fixture assertions, not additional Gradle/JUnit methods or physical checklist passes. + +The daemon ran as UID 1000 in a filesystem/user/PID sandbox exposing only read-only `/usr` and private +account files/home. Its password and SSH identities were newly generated for this test. It listened +only on loopback, with PAM, keyboard-interactive, forwarding, PTY and source penalties disabled. +This Fedora build warns that `UsePAM no` is unsupported; the asserted crypt/password and public-key +wire/exec flow nevertheless passes in that exact controlled configuration. This does not verify a +normal PAM/account backend, MFA, macOS, Android setup UX/Keystore, native cancellation/timeouts, +or profile/managed-session behavior. The earlier MINA tests retain their separate coverage. +Both disposable daemon runs are closed; their generated passwords/private keys/account hashes were +removed after the run. No host account, credential, SSH configuration, retained phone identity or +phone state was changed. Private proof: `.nodeterm/android-beta-build-16/openssh-native/`. + +The stale SSH class comment now describes A111's host-owned managed New support; missing existing +canvas sessions remain attach-only. It changes only KDoc, with the same source line count. +The prepared beta-16 APK remains source `2723845e`, code 17 and the same retained signature/checksum; +no new APK is built or installed for this verification/comment checkpoint. Fresh compilation in the +same AGP graph confirms every protocol class byte matches the immutable beta-16 build. This byte +comparison is a required gate before publishing the receipt. Required local gates and exact-head Android CI also remain mandatory. +Phone checks stay paused at **10 Pass / 22 Partial / 32 Pending**, with beta 10/code 11 last installed. +Actual Pixel setup/lifecycle, ordinary PAM accounts, macOS and live Claude checks remain open; +A25/A93 still need hosted-backend maintainers, iOS follow-up stays with @eneskirca, and A68 remains +deferred until a requested PR. No PR opened. + +### Relay-advertisement producer/SSH coverage (`A64`, 2026-10-05) + +Two actual private-SSH regression methods now check every public field from the desktop's +account-level `relay.json` writer, replacement, removal and profile isolation. A third method +checks the actual writer/remover and `fs-atomic` bundle inputs against Android CI path coverage. +All 75 affected protocol methods pass with zero failures/errors/skips. Four isolated behavior +mutants fail assertions, and control/restored runs pass. Private proof: `.nodeterm/android-beta-build-16/desktop-links-and-advertisement-receipt/a64/`. +The producer uses a temporary OS-home adapter during module initialization, restored before its +operations; no standing relay host, token mint, adoption, SAS, revoke or phone result is claimed. +No production contract or APK changes. Beta 16/code 17 remains prepared, beta 10/code 11 remains +installed, and phone checks stay paused at **10 Pass / 22 Partial / 32 Pending**. + +## Canvas terminal link leave follow-up (A121, 2026-10-05) + +The full Linux Desktop native hover check for A92 exposed a separate Canvas leave defect: +synchronous blur redraws xterm's DOM rows before its screen receives mouseleave, leaving the +link pointer active after a native non-drag leave in the SGR mouse case. A121 now defers only +owned blur to the next timer task and cancels it on re-entry, focus and cleanup before park. +Nine behavioral methods, all 63 affected terminal tests, full TypeScript and five assertion +mutants pass. Fresh full Linux Desktop control and restored builds each pass all eight native +UI cases at `01b4a2f5`; the old-bound A92 and synchronous-blur A121 mutants fail on their named +hover/leave assertions after trusted mouse delivery. Separate Linux Server checks pass at +`dcdf664a`. The later GPU-enabled eight-case matrix passes (Canvas4 hardware / Modal4 DOM); GPU +context recovery and native macOS remain unverified. No Android runtime, APK, external contract +or iOS change is involved. Phone testing remains paused: beta 10/code 11 is last installed, +beta 16/code 17 is prepared, and the physical ledger stays **10 Pass / 22 Partial / 32 Pending**. + +## Native Desktop wrapped-link and leave verification (A92/A121, 2026-10-05) + +Signed source `01b4a2f5` now passes the full actual Linux Electron Desktop UI check. Four fresh +full builds consume all **2,462 exact raw Git blobs**, with only the one approved source literal +changed in each mutant. Control and restored runs each pass all eight combinations of Canvas +and maximized Board modal, hard/soft wraps, and normal/SGR mouse reporting. A real plain native +NodePTY paints the long run; native trusted XTest tail hover shows the entire URL underline and +pointer, plain click opens nothing, Ctrl-click opens exactly the complete URL once, and a native +non-drag leave clears the pointer and underline. No direct provider or activation call is used. + +The old upward cap fails with `A92_TAIL_HOVER_MISSING` after calibrated native tail hover. +Restoring synchronous Canvas blur fails with `A92_HOVER_CLEAR_FAILED` after trusted hover, full +Ctrl-click activation and native leave, retaining pointer/link state. The final blur mutant fails +in Canvas soft/SGR; an earlier run also reproduced it in hard/normal mode. Nine controller tests +and five assertion mutants cover cancellation, replacement, same-object park/adopt and cleanup +reuse; all 63 affected terminal Vitest methods and full TypeScript pass. + +Private durable proof: `.nodeterm/android-beta-build-16/desktop-links-and-advertisement-receipt/` +contains `a92/results.json` with 72 source/build/runtime evidence files, `a121-unit/results.json` +and `a121-affected/result.json`. Network/PID/home isolation and unchanged source/tools/held output +checks pass. Earlier archive-line-ending refusal, phase-specific classification rejection and +quit timeout are preliminary recipe results; they were not counted as product failures or final +controls. The final disposable fixture uses explicit exit after recording the checks; ordinary +app quit is outside this proof. Synthetic SGR reporting does not verify a real TUI/tmux/SSH path, +Server Edition, GPU, macOS or Android. No APK, host/client contract, iOS adoption or physical +checklist promotion follows. Phone testing remains paused at **10 Pass / 22 Partial / 32 Pending**. + +## Native Server wrapped-link and leave verification (A92/A121, 2026-10-05) + +Signed source `dcdf664a4ffa0cf752350c29fc21843f74c93ae1` passes separate genuine Linux Server +control and restored runs, each with all eight Canvas/Board-modal, hard/soft-wrap and normal/SGR +cases. Four fresh full builds use 2,462 raw Git blobs; each separate `server:build` adds only +`server/main.cjs` to the unchanged 136 artifacts. Actual Node 26 runs the shipped Server entry. +Chromium loads its real login form and HTTP renderer with no Desktop preload, Node integration +or injected API. Passive observation confirms authenticated `/ws` handshakes, actual PTY create +requests/replies and binary output. Native trusted hover, full underline, plain/Ctrl click and +non-drag leave assertions pass. Both the old-cap and synchronous-blur mutants fail their named +assertions after calibrated native input; restored source passes again. + +Private proof: `.nodeterm/android-beta-build-16/server-links-receipt/a92/results.json` and its +105-file manifest bind source, builds, recipes and runtime. Three earlier browser-bootstrap +failures remain separate infrastructure evidence, with no terminal assertions or product finding. +Loading the actual login document before enabling debugger network observation resolves the +runner stall. Final owned Node children close on SIGTERM; disposable Chromium exits explicitly +after the report. This verifies Linux Server DOM rendering with a synthetic reporting-mode TUI; +real tmux/SSH, GPU, macOS, ordinary quit and Android UI remain outside its scope. + +No runtime or external contract changes follow from this verification. Beta 16/code 17 remains +prepared from `2723845e`, uninstalled; beta 10/code 11 is last installed. Phone testing stays +paused at **10 Pass / 22 Partial / 32 Pending**. Next: resume the Pixel update/checklist when +authorized; live A105/A106 needs a usable provider account after HTTP 401; A25/A93 need hosted +backend maintainers. The Android contribution is in this repository. No PR is opened; A68 waits +for requested PR preparation. + +## Native setup timing, installed PAM and remaining desktop checks (2026-10-05) + +A120 now passes four native OpenSSH cancellation/deadline cases at clean source +`7cf2bb339d73c98e7876a7ce1edec5f6f3585141`, with **76 case-scoped assertions**. +All 76 protocol source files were compiled privately with Kotlin 2.2/JDK 21 and SSHJ 0.39. +The pre-install gate admits real password authentication and the exact production exec request, +then holds it without running the installer; cancellation/deadline leaves authorized_keys unchanged. +The post-install gate retains the actual installed key, accepts a third connection and reads a +real upstream SSH banner before withholding it. That case does not complete a third key exchange +or key authentication. External cancellation remains CancellationException; the operation's own +12-second post-dispatch deadline reports HostUnansweredException. Neither returns a PairedHost. +Owned client sockets, gates and daemon descendants close; caller password buffers clear, consumed +confirmations cannot replay, unrelated key bytes/modes survive, and generated secrets are removed. +This is protocol/native proof, not physical HostStore, Keystore or Android background/setup UX proof. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/openssh-timing/`. +The earlier 28 distinct native assertions / 40 events and 17 JVM methods / seven product mutants +retain their separate inventories; these new fixture checks are not new JUnit methods or mutants. + +A separate **17-check native installed-PAM control** passes against OpenSSH 10.2p1 and this host's +unchanged Fedora PAM stack. It uses a namespace-only synthetic NSS/shadow account and a UID/GID +1000 client with every process capability cleared. Actual logs record one accepted password, +two accepted key authentications, three successful PAM account checks, and three matched session +opens/closes, with no PAM session/credential errors. Public fingerprint confirmation, independent +retained-key exec, wrong-password/pin refusals, key-file preservation, no replay and socket cleanup +pass. All 31 public installed-stack bindings remain unchanged, SELinux remains enforcing, and +generated credentials plus owned daemon descendants are removed. This reuses 513 class files from +the earlier timing compilation after verifying all 76 protocol source bytes; only the 11 standalone +driver class files are freshly compiled. It does not verify real-user policy, MFA, ordinary systemd +user-session registration, macOS, or Android UI/storage. Earlier setpriv and private-log traversal +failures are retained as failed fixture attempts, without product fixes or mutation credit. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/runtime-checkpoint-supplement/`. + +The earlier RTX 4090 WebGL2 probe verified the environment only; the successful PAM namespace +admission performed no authentication. Their historical scope is unchanged. Later product GPU +and installed-PAM results supply the separate evidence recorded here. + +Earlier focused-terminal Ctrl+Q attempts remain nonpositive. V5's trusted renderer capture and +main before-input observations do not establish `defaultPrevented` after xterm's target handler. +Minimal Electron Quit controls verified the environment, not product Quit; failed fixture and +classifier attempts remain separate from the successful runs below. + +**Historical Linux Desktop runtime checkpoint:** current source `972f7994` differs from built +Desktop `01b4a2f5` only in six documents. Native File-menu Quit/Cancel and a separate outside-terminal +Ctrl+Q/Cancel both pass, followed by native File-menu confirmation and natural zero exit. Cancel +preserves the exact own PTY/workspace/window; Quit retires the own PTY/hook and saves state. +The GPU-enabled eight-case link matrix passes with eight complete URL opens: four Canvas cases +use the actual NVIDIA RTX 4090 xterm WebGL2 context, real glyph pixels and production 2D link +underline before/hover/leave; four Modal cases intentionally use DOM. The single GPU pilot stays +separate. A real shipped-tmux Canvas soft-wrap/inner-no-reporting pilot also passes one native +hover/plain-click/Ctrl-click/leave case while preserving the exact pane/client and shipped mouse-on +options. GPU and tmux fixtures use explicit `app.exit(0)`, supplying no ordinary-Quit proof. +These do not verify GPU context recovery/compositor pixels or full tmux/SSH matrices. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-checkpoint-20261005/`. + +**A122 is fixed in `9613cec519c1469aff10371bf0acf97adb90ed1a`.** Desktop Canvas/Modal now keeps the +exact native Quit chord ahead of xterm on keydown/keyup under either shortcut policy, without +preventing the native menu. Server retains Ctrl+Q/XON. 133 focused policy tests, 218 affected tests +in three files, full TypeScript and six assertion-caught isolated mutations pass. The fresh full +Desktop build is bound to that exact source (snapshot `57e09375369a1a68f0c28b7975616561aad6715a4dc05a6fc5eeb00d0dc0706c`; +build receipt `86eb82912ab7aa8a853a7236bacebc40ffbd9eff6845f3b79caaac83a029f5ff`). +All four native Linux cases pass: Canvas/Modal × app-first/terminal-first. At dispatch the actual +owned xterm textarea and main focus IPC are true; Ctrl+Q opens the real parented Quit dialog. +Native Escape Cancel retains the exact PTY and saved state; native File → Quit / Tab / Enter then +exits naturally with code zero, retires every admitted PTY birth and removes the own hook endpoint. +No signal, `app.exit`, renderer-dispatched key or API Quit supplies a pass. These are four separate private +home/network/PID/Xvfb cases, with zero link cases/URL opens; native macOS/Windows remains unverified. +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-native-quit-20261005/`. + +The A122 source repair changes Desktop shortcuts only; Android and external contracts are unchanged. +Beta 16/code 17 remains prepared from `2723845e`, uninstalled; beta 10/code 11 is last installed. +Phone testing stays paused at **10 Pass / 22 Partial / 32 Pending**. The later tmux/SSH and +GPU follow-up below keeps its own source bindings; other-platform runtime remains unverified. +A105/A106 live checks need a usable provider after HTTP 401; +A25/A93 need hosted-backend maintainers. Resume Pixel checks when authorized. No PR is opened, +and A68 remains deferred until requested PR preparation. + +## Tmux, SSH and GPU recovery follow-up (2026-10-05) + +The fresh full Linux Desktop build at `6bfa5412` passes two separate eight-case matrices: +Canvas/Board modal × soft/hard wrap × inner mouse reporting off/SGR. Each matrix delivers +52 native mouse actions and eight complete URL opens through shipped xterm handlers. The local +matrix uses real shipped tmux; the remote matrix uses an actual SSH project, production +SshProjectManager/OpenSSH ControlMaster and remote `nodeterm-rmt` tmux. Exact pane, server and +client births survive every case. SSH retains its connection notice in history and calibrates the +actual viewport offset; the fixture does not erase it. Own session and SSH service cleanup pass. +These fixtures end with explicit app exit and supply no ordinary-Quit or SSH-reconnect proof. +Private proof: `desktop-tmux-matrix-20261005/` and `desktop-ssh-matrix-20261005/` below +`.nodeterm/android-beta-build-16/operation-verification-receipt/`. + +Two separate one-terminal GPU pilots at that same source pass delayed loss → readable stock DOM +→ fresh NVIDIA WebGL2 and quick original restoration → DOM → fresh WebGL2. Both retain the same +49×48 buffer/PTY and three trusted native input acknowledgements, with complete URL interaction +and glyph checks before/after. Quick restoration also exposes A123: the detached original +context remains live after the new renderer is granted. Chromium cap-pressure consequences are +an inference; no page-wide pressure failure was reproduced. + +A123 is source-fixed in `db83fb7d`: retire the captured original context after addon disposal and +before reporting context loss to the budget coordinator. Two new behavioral tests cover detached +canvases and retirement continuing after a throw; all 44 budget tests and 301 affected tests in +seven files pass with full TypeScript checking. Full 44-test control/restored helper runs catch +two exact assertion mutants. Three separate fresh full Desktop builds at `db83fb7d` verify the +actual restore hook: both fixed controls release the original context before fresh regrant, while +the compiled sole-call-removal mutant fails only `GPU_RETIRED_CONTEXT_NOT_LOST` after all three +native input acknowledgements and both complete URL controls pass. A capture-phase native event +witnesses the genuine restoration before production retires it; a passive second-loss event and +DOM/fresh/final checks verify retirement. Fixture probes retain the original context for observation; +no forced GC or second fixture loss supplies a pass. These are one-terminal Linux Canvas pilots, +with explicit fixture app exit rather than ordinary Quit. Private proof: `gpu-context-recovery-20261005/` +for historical recovery and `a123-context-retirement-20261005/` for the new source/compiled mutants. +The older 6bfa and A122 source/build bindings remain separate. + +**Additional Server verification (`fa259f6e`).** Three fresh genuine Linux Node Server/browser +Canvas soft/none runs pass fixed control / sole-call-removal mutant / restored confirmation +with natural fixture exits 0 / 3 / 0. Each authenticates through the shipped HTTP login and WS +bridge, without Desktop preload, and retains the same plain NodePTY, producer, 49×48 +buffer and geometry. Three trusted native input ACKs and two complete URL/glyph controls complete before +the retirement assertion. Fixed runs witness genuine original restoration, production loss of that retired +context, stock DOM fallback and fresh NVIDIA rendering; the calibrated mutant fails only +`GPU_RETIRED_CONTEXT_NOT_LOST`. Owned Server SIGTERM closes it gracefully with zero and removes +its PTY/producer; fixture app exit supplies no ordinary-Quit proof. This is one visible Server +Canvas pilot, not a Server GPU matrix or pressure test. + +**Additional SSH reconnect verification (`fa259f6e`).** One Linux Desktop Canvas soft/none +pilot passes after a single controlled SIGKILL of the exact product ControlMaster. Actual SSH +exit 255 triggers shipped automatic recovery; a separate strictly pinned read-only observer +verifies the same remote tmux pane and server, session-created identity, full history and producer across +the gap and recovery. A new master, local PTY and remote attachment are required. Two trusted +native `g` ACKs and complete URL hover/plain-click/Ctrl-click/leave checks pass with one producer +paint. Exact owned session, observer, master and daemon cleanup passes; explicit fixture app exit +provides no ordinary-Quit proof. Observer reads may affect timing, so this proves no performance +or backoff guarantee. Broader SSH reconnect matrices remain pending; this pilot supplies no +Modal, Server, Android or PAM enrollment proof. +Earlier V2 nonce admission and V3 service-process failures remain fixture negatives; V3's outage +state was not established. Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/desktop-ssh-reconnect-20261005/`. + +**Derived Desktop compositor evidence (`db83fb7d`).** A separate hash/CRC-checked analysis of +three existing positive-run `capturePage` PNGs compares an uncovered repeated `A` cell and a blank +cell with the actual framebuffer RGB references. All three match 152/152 pixels in each cell; +42 glyph pixels differ from the blank background. The Sessions-covered top-left cell is excluded, +not a product failure. Recorded geometry establishes 1:1 capture/CSS/GL scales at zoom 1; +a direct device-pixel-ratio query was not recorded. This adds a single-run compositor cell +correlation without rerunning the product or relabelling its old receipts. It does not establish +synchronized frames, whole-frame or physical-display output, or link-underline compositing. + +Private proof: `.nodeterm/android-beta-build-16/operation-verification-receipt/a123-server-context-retirement-20261005/` +contains **80 evidence/recipe files plus its manifest**. Server runtime and historical Desktop +cell derivation remain separate; the original Desktop receipts are unchanged. + +Android and external contracts are unchanged. Beta 16/code 17 remains prepared from `2723845e`, +uninstalled; beta 10/code 11 is last installed. Phone testing remains paused at **10 Pass / +22 Partial / 32 Pending**. Repeated/page-wide GPU pressure, shared glyph eviction, synchronized/ +full-frame compositor or physical-display output, broader SSH reconnect, macOS/Windows and the remaining +Pixel checklist are unverified. Live +A105/A106 needs usable provider authentication; A25/A93 belong to the hosted-backend maintainers. +A68 is source-fixed for branch readiness; no PR has been opened. + +**A68 workflow readiness (2026-10-05).** Source fix `7e91785c` limits Android +push runs to `main` and retains pull-request path filters, without `merge_group`. Feature-branch +pushes no longer duplicate PR checks or automatically prepare beta inputs. Manual +`workflow_dispatch` with `prepare_beta=true` runs all five Android jobs; beta version overrides +and **Private beta checks** are confined to that opt-in. The registered workflow `366764274` +accepts API/CLI dispatch on the selected ref; verify the run's exact head before using its results. +Pre-cutover control [run `37260849252`](https://github.com/CPlusPlus17/nodeterm/actions/runs/37260849252) +was dispatched at `c371860f` with `prepare_beta=true`: all five jobs passed. +That control establishes dispatch admission, not verification of the later A68 source fix. +All 13 focused trigger/path methods pass. All eight actual workflow mutations fail the real +configuration test, with all 13 methods passing before and after restoration. The full +949-method/98-suite protocol gate and offline app compilation are required before push. +After push, exact-head CI is required through `workflow_dispatch` with `prepare_beta=true`; +the verified run and exact head are recorded with the source exports. This closes the source +finding; no PR is opened by this checkpoint. APK/client bytes, private signing, phone pause +and **10 Pass / 22 Partial / 32 Pending** are unchanged. + +## Native custom wheel checks (2026-10-06) + +`SshTransportTest` adds `SSH scrolling honors custom wheel bindings in both copy key tables`. +It uses the actual SSH connection and `TerminalActions` with a native tmux server in a private +`TMUX_TMPDIR`. It seeds 750 output lines, selects each copy key mode and installs asymmetric +wheel bindings. Requests of 37, 23 and 29 notches exceed one transport chunk; the host bindings +define the history distance rather than a client-side normalization. + +| Mode | Up/down row gains | Up37 | Then down23 | Then up29 | Queued up37/down73/up29 | +| --- | --- | --- | --- | --- | --- | +| Emacs / copy-mode | 3 / 2 | 111 | 65 | 152 | 87 | +| Vi / copy-mode-vi | 7 / 4 | 259 | 167 | 370 | 203 | + +Each native `scroll_position` must settle for 300 ms within an eight-second deadline. The queued +sequence reaches the live bottom before returning to history, making its final result sensitive +to direction ordering. The fixture verifies exact restoration of the original local mouse/mode +overrides and both affected wheel bindings, including absent overrides. + +The targeted offline control passes **one test, zero failures/errors/skips**, in 14.135 seconds. +A separately isolated network/PID/home/source control and restored run each pass the same eight +positions. Three actual transport mutants fail the exact first-distance assertion: reversed +direction and a dropped wheel write produce 0; clamp20→1 produces 6, versus 111 expected. +Setup/cleanup assertions do not count as caught mutations. The first idle-copy-mode cancellation +mistake stopped the test before scrolling; its negative receipt remains separate from the corrected +control and earns no product finding or mutation credit. + +Source base is `e6dc6ef3` plus the new test (SHA-256 +`5cd81c49f36744393d98fcfabd6c73ee086f5cb25e1940ce43811c1ac17fe3cc`). Private control proof: +`~/.cache/nodeterm-android-work/a86-custom-wheel-native-kkvz22xp/`; mutation proof: +`~/.cache/nodeterm-android-work/a86-native-wheel-mutation-yo4k_bju/proof/`. +The mutation receipt binds all 2,462 tracked files unchanged before/after; production sources +are unchanged. Before publication, the full offline protocol/app gates and exact-head five-job +Android CI still apply. This is native copy-mode transport/ordering evidence, not phone gestures, +FPS, broader lifecycle/outage or macOS/Windows proof. Beta 16 and the original Pixel's +**10 Pass / 22 Partial / 32 Pending** ledger remain unchanged. Next: continue the remaining +physical matrix when the intended Pixel is reachable; A25/A93 still need the hosted backend. + +## Desktop card reconnect repair (2026-10-06) + +A125 is source-fixed in `415dae9b`; A126 is committed in `5b7286b3`. +A125 replaces only the lost open card viewer after the coordinator succeeds and passes the +owning project through both boards. The card stays open, retains persistKey/requireRemote and +never replays an initial, pending or agent launch command. Its 13 new cases/100 affected tests +and six assertion-caught mutants retain their separate control/restored evidence. +A126 chooses one live automatic responder per exact `api.pty`/actual session in this renderer, +while preserving genuine input/focus and deterministic promotion. Its 16 new cases comprise nine +installed-xterm checks, one fallback shim and six fake-rendering mounted Modal cases with real +helper/LocalTransport/coordinator. +**294 unique affected tests / 17 files** and full incremental TypeScript pass. **15 isolated +mutants (six A125 + nine A126)** are caught by assertions; each fix's control/restored runs pass. +Forced offline checks at `5b7286b3` execute **950 tests / 98 suites**, zero failures/errors/skips, +and both protocol/app Kotlin compile tasks. These are disposable QA hosts; no new APK or regular +Desktop installation is included. Publication requires fresh offline gates and all five Android +CI jobs for the exact published HEAD; its private bundle and final report carry that run's result. + +Eight actual Linux Desktop **Canvas/Modal × soft/hard wraps × inner none/SGR mouse** cases +pass at `5b7286b3`. Each loses one birth-pinned product ControlMaster, creates a new master/ +selected local viewer and retains its remote pane/server/producer/cwd and complete same-grid history before, +during, after and at final observation. Both trusted actual textarea `g` inputs are acknowledged +on the old/new PTY: **16 ACKs**, **zero unexpected bytes**, one producer paint per case and +**16 complete URL activations**. Native hover/leave and exact owned cleanup pass. Modal cases +use `80x24`; Canvas cases use `49x48`, with each history comparison bound to its own grid/case. +Private native aggregate: `~/.cache/nodeterm-android-work/a125-a126-native-matrix-ww8e8nwo/root-acceptance.json`, +SHA `d8a5366b83d4c4b3ada9a1112521f762905ce3b835be4a32983ce9150f4f391a`. +It binds all eight receipt/runtime hashes, geometry/history/timing and exact cleanup. + +The historical `11e22453` stale-Modal negative and first `415dae9b` control retain their original +provenance. The latter creates replacement viewers but records 18 unexpected DA1/DA2 bytes after +ACK 1; ACK 2 is not attempted. Its unclassified wrapper/runtime exits 1/2 remain separate from the +later A126 diagnosis. Those failed controls are not rewritten as repaired-source positives. +See [audit A125](android-audit-2026-09.md#a125) and [audit A126](android-audit-2026-09.md#a126) for exact evidence. + +**Two-host follow-up (2026-10-06).** One Linux Desktop soft/none case at `59e4c93e` passes in +27.39 seconds: an ordinary quiet B park/adopt cycle, then inactive B's continuously open global +card reconnects while A remains active and unchanged. Three native `g` ACKs, two complete URL +activations, zero unexpected input, one paint per role, same-grid retained history and exact owned +cleanup pass. Carried SID provenance is primary create/native Canvas `g1` plus unchanged writer/ +subscriptions/client; no adopted Canvas-input/current SID-ref result is claimed. See [the bounded two-host follow-up](#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06) +for exact source/proof hashes and the remaining scope; no new repair or product test count is added. + +Actual Canvas mounting and selected-view native keyboard recovery pass in the eight-case matrix; +one additional two-host soft/none case verifies a quiet park/adopt cycle and inactive global-card +recovery with the active host unchanged. See [the bounded two-host follow-up](#inactive-global-card-owner-and-bounded-parkadopt-2026-10-06). Remaining Android release acceptance needs a +reachable intended phone for lifecycle/outage, answer/notification/permission and live-provider +checks; A25/A93 need hosted-backend maintainers. Further host coverage includes streaming/expiry/ +eviction while parked, repeated or broader outage/backoff, Server/cross-window response ownership, +GPU pressure/full-frame display output and macOS/Windows. The synthetic key-only hosts do not +establish real-user PAM, power-loss, agent-launch or ordinary Quit behavior. Unsupported private +xterm shapes warn and retain input but may duplicate replies; legacy onBinary is unchanged. +No Android wire/file or iOS client change is required. The original Pixel 10 Pro ledger remains +paused at **10 Pass / 22 Partial / 32 Pending**. diff --git a/docs/hook-reply-approvals.md b/docs/hook-reply-approvals.md index 3ee77e9b5f..ec2f740d61 100644 --- a/docs/hook-reply-approvals.md +++ b/docs/hook-reply-approvals.md @@ -1,15 +1,18 @@ -# Hook-reply approvals — deterministic Approve/Deny (v1 contract) +# Hook replies — owned approval rules and complete questions (v2 contract) -Inspired by claude-island's EventServer: its permission hook holds the HTTP request open and -the UI's Allow/Deny **is the hook's reply** — no keystrokes, no prompt-layout coupling. This -doc adapts that to nodeterm's architecture, where the answerer may be a **phone reaching the -host over SSH** (no route to the desktop's loopback server), so the reply channel is a -**file on the host the agent runs on** — reachable by every answerer we have. +The managed Claude hook answers held requests through a private file on the computer running the +agent. A phone can reach that file over SSH, or use the standing desktop's typed relay verbs. The +answer is hook JSON; it does not depend on a permission dialog's numbering or which terminal pane +has keyboard focus. Legacy one-line `allow` and `deny` replies remain supported; plans and held +questions have the event-specific rules below. -## Why replace send-keys +Claude documents rule updates through `PermissionRequest.decision.updatedPermissions` and question +answers through `PreToolUse.updatedInput`. The latter must retain the questions and provide the +chosen answers; allowing the tool alone does not answer it. See the official [permission update +entries](https://code.claude.com/docs/en/hooks#permission-update-entries), [tools requiring user +interaction](https://code.claude.com/docs/en/hooks#tools-that-require-user-interaction), and [question +answer representation](https://code.claude.com/docs/en/agent-sdk/user-input#return-answers-to-claude). -The phone's quick-approve today types `1`/Escape into tmux. It depends on the permission -prompt being on screen, focused, and numbered the way we assume. Hook-reply is deterministic. On the main thread Claude Code runs the hook CONCURRENTLY with the painted dialog — whichever answers first (the hook's decision or the user in the TUI) wins and the later one is ignored (research: docs/superpowers/plans/2026-09-26-answer-paths-research.md §1; an earlier version of @@ -17,7 +20,12 @@ this doc said the decision lands "before the prompt is painted", which is true o subagent's request, whose dialog awaits the hook). On timeout the hook prints nothing and the dialog simply stays (fail-open, bit-for-bit legacy). -## Mechanism +## Hold and release + +`managed-script.ts` revision 6 holds a Claude `PermissionRequest`, or a parent `PreToolUse` for +`AskUserQuestion`, only while `NODETERM_PERM_WAIT_SECS` is positive. The default injected wait is +45 seconds when `hookReplyApprovals` is enabled. With no wait environment, the hook remains inert +for this feature; ordinary tool events and child question events are not held. **Request** — the managed hook script's `PermissionRequest` branch (env-gated like everything else in `managed-script.ts`), only when `NODETERM_PERM_WAIT_SECS` is set (> 0) in the session @@ -36,33 +44,36 @@ env: 6. Timeout: `rm -f` the request file, print **nothing**, exit 0 → Claude shows its normal prompt; legacy send-keys still works as the fallback. -POSIX sh only, no deps — same constraints as the existing managed script. The wait branch -must be a **no-op** when the env var is absent (user's own terminals, older nodeterm). - -**Answerers** (all write the same one-line answer file, atomically `printf > tmp && mv`): -- **Phone (SSH)** — `InboxApproval` writes it over the connection when the approval event - carries `pendingId`; else falls back to send-keys. Digit `2`/"Always allow" keeps using - send-keys in v1 (hook `updatedPermissions` is out of scope). -- **Desktop canvas** — the NEEDS-YOU badge gains Approve/Deny buttons (approval events with - `pendingId` only), routed over IPC to a main-side writer: local project → local fs; SSH - project → write via the project's ControlMaster. So desktop users are not left staring at - a held prompt — they get one-click approval the moment the badge pulses. - -**Event plumbing** — the hook server's raw `PermissionRequest` payload now carries -`nodeterm_pending_id` (added by the script to its POST body); the mirror's approval -`InboxEvent` gains `pendingId?: string`, riding the mirror (phone) and dropped from the -push-notify POST body (the APNs payload doesn't need it — the phone re-reads the mirror -before acting anyway). - -**Env injection** — `buildPtyEnv` adds `NODETERM_PERM_WAIT_SECS=` when the new setting -`hookReplyApprovals` (default **on**) is enabled AND the agent is claude (the only CLI whose -PermissionRequest hook decision contract we've verified). Setting lives beside the mobile-push -settings; off ⇒ env absent ⇒ script branch inert ⇒ exact legacy behavior. - -**Cleanup** — the hook server sweeps `~/.nodeterm/pending/` for files older than 10 min on -boot and hourly (orphans from killed sessions). The phone/desktop never create answer files -for pendingIds they didn't read from a live approval event, and re-check the event is still -unresolved before writing. +The script writes the original stdin JSON to +`~/.nodeterm/pending/--.json` under `umask 077`, then reports the ticket and +`nodeterm_hook_reply=2` through the ordinary hook POST. It polls the corresponding `.answer` every +0.5 seconds. After reading an accepted reply it removes the request and answer, reports the consumed +answer through the existing hook endpoint, prints the decision JSON and exits. Timeout removes the +request, prints no decision and returns to the CLI's normal interactive flow. + +The feature still uses the existing managed hook, endpoint and credentials. It creates no hosted +service, account, permission-mode bypass or independent prompt simulator. + +## Answer formats + +An ordinary permission reply is still exactly `allow` or `deny`. There are two additive structured +formats. The Android v2 remembered-rule and complete-question reply is: + +```text +nodeterm-hook-reply-v2 + +``` + +The marker lets the managed consumer distinguish the additive format from legacy files. It accepts +only the output kind matching the hold: a remembered rule is a `PermissionRequest` decision, and a +question is a `PreToolUse` decision. Plain permission replies cannot answer a held question. + +The builders in `src/shared/hook-answers.ts` and Android's `HookReplies.kt` validate the original +request and produce this JSON. UI/RPC callers supply indexes, never arbitrary rules or hook output. +Structured writers refuse original requests larger than 128 KiB, and generated decision JSON is +bounded to the same size; the marker is separate framing. The separate structured chat contract +below writes an unmarked, single-line `PermissionRequest` decision, capped at 64 KiB. It answers +`PermissionRequest` plan/question holds; it cannot answer a v2 `PreToolUse` question hold. ## Plans and questions (structured answers, 2026-09) @@ -90,8 +101,9 @@ multiSelect question, EVERY question the request asks must be answered (a partia the TUI never submits a half-answered picker), sizes capped (text 8000 chars, the whole decision 64 KB). A structured answer is refused when the request file is gone (the hold ended) — the call resolves `false`. -**What the script prints** (`managed-script.ts`, tested under a real `/bin/sh` in -`managed-script.answer.test.ts`): only (a) the fixed decisions for the words `allow`/`deny`, or (b) +**What the PermissionRequest branch prints** (`managed-script.ts`, tested under a real `/bin/sh` in +`managed-script.answer.test.ts`): (a) the fixed decisions for the words `allow`/`deny`, (b) a matching +marker-framed v2 remembered-rule decision, or (c) the answer file VERBATIM when it starts with exactly `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":` + `"allow"`/`"deny"`, ends with `}`, is at most 64 KB and has no control bytes (so it is one line). Anything else prints @@ -99,7 +111,7 @@ nothing — exactly what every earlier build did with an unknown answer. The ans decoded VERB, never the file (a structured answer holds user text, and nothing from it may reach an argv). Remote answers travel on the SSH command's stdin for the same reason. -**The plain words, per tool** (so the phone and the header button need no change): +**The plain words in a PermissionRequest hold, per tool**: | answer file | ExitPlanMode | AskUserQuestion | anything else | |---|---|---|---| @@ -111,7 +123,12 @@ printing it would only end the hook, closing the chat-view answer path while cha the TUI. Core also refuses to write it (`answerPermission` → `false`) so no surface flips the badge for an answer that did nothing, and the header hides ✓ Approve for that ticket. -**Hold time.** For these two tools the hook holds `PERM_WAIT_SECS_INTERACTIVE` = 540 s instead of 45 s +A separately held v2 `PreToolUse` question accepts only its marker-framed question reply. +Permission words and unmarked `PermissionRequest` decisions are consumed without releasing that +hold; they print no decision and send no consumed-answer POST. + +**Hold time.** For these two tools' `PermissionRequest` hooks, the script holds +`PERM_WAIT_SECS_INTERACTIVE` = 540 s instead of 45 s — people read plans for minutes. The installer writes an explicit `timeout: 600` on OUR PermissionRequest handler (`PERMISSION_REQUEST_HOOK_TIMEOUT_SECS`, `CLAUDE_HOOK_EVENTS`; local, managed-account and SSH installs alike, and an existing install is rewritten with it at the next install), so the bound the hold @@ -125,23 +142,30 @@ undefined (and dropped by JSON) on the main thread; a nested false positive only The tool name is read from the FIRST `"tool_name":` in the payload and trusted only when no `"tool_input"` precedes it, so a nested key inside some tool's input can never make an ordinary tool look like a plan (an empty/unsafe name degrades to today's default behavior). The answer file's size is -its BYTE count (`wc -c`) and at most cap+1 bytes are read (`head -c`). +its BYTE count (`wc -c`). The consumer reads at most one byte past the larger marker-framed cap +(`head -c`), then applies the chosen format's own size bound. +The separate v2 `PreToolUse` question keeps the explicitly armed `NODETERM_PERM_WAIT_SECS` +(normally 45 s); it does not inherit the longer plan/question permission hold. **Old script on an SSH host — gated by revision.** The script on a host is rewritten only at connect, so a long-connected project can hold a request with an older script. That script reads a JSON answer as neither `allow` nor `deny`, deletes it and prints nothing (the TUI still answers — verified by running the new sh tests against the previous script), while the WRITE succeeded; without a gate core would report success and the optimistic "answered" event would flip NEEDS YOU to working over an agent still waiting -in its TUI. So the script's revision gates it: `MANAGED_SCRIPT_REVISION` is 5, every hook POST already +in its TUI. Both parent branches used revision 5 for different answer formats, so that number cannot +prove support for unmarked structured decisions. The merged contract uses `MANAGED_SCRIPT_REVISION` +6, every hook POST already carries it (`clientRevision`), and the hook server (`labelHeldForRevision`) keeps an event's `held` ticket -— and records the ticket as structured-capable — only for revision >= `MIN_STRUCTURED_ANSWER_REVISION` (5). +— and records the ticket as structured-capable — only for revision >= `MIN_STRUCTURED_ANSWER_REVISION` (6). `answerHeldPermission` refuses (`false`, nothing written, no synthetic answered event) a structured answer for a ticket not recorded as capable, and a plain `allow` on a plan held by such a script (it would print a -bare allow Claude drops). **UI consequence:** on an old-script host no plan/question controls are offered -at all (the renderer never receives `held`), and the header ✓ Approve on a plan answers `false` instead of +bare allow Claude drops). **Chat UI consequence:** on an old-script host no unmarked plan/question +controls are offered (the renderer never receives `held`), and the header ✓ Approve on a plan answers `false` instead of pretending; Deny and ordinary approvals work exactly as before. Reconnecting the project installs the current script. The capability record is process-local and bounded, so a ticket from before an app restart is treated as not capable (the renderer's `held` is gone then too). +Android's separately advertised marker-v2 rules and full questions remain available on a revision-5 +v2 host; the unmarked structured-answer gate does not remove those capabilities. **Renderer.** A held request's `{pendingId, toolName}` rides the normalized event as `held` and the agent-status store keeps it while the node is `blocked` or `waiting` — separate from `pendingId`, which @@ -191,6 +215,103 @@ structured answers from the phone are an iOS follow-up. ## Out of scope -- "Always allow" via hook `updatedPermissions`. +## Allow and remember + +An approval may advertise `permissionSuggestions: [{index, label}]`. Each index points to a +concrete `addRules` suggestion from that request's original `permission_suggestions`, with `allow` +behavior, the same tool, explicit nonblank rule content, and one of `session`, `localSettings`, +`projectSettings` or `userSettings`. Omitted whole-tool scope, a literal `*` scope, other tools, +`setMode`, replacements and unsupported destinations are not offered. + +The Android card opens a confirmation showing each exact rule and destination. The desktop/Server +canvas offers the same request-derived rule scopes beside Approve and Deny. A remembered reply +uses the original rule contents and destination under +`hookSpecificOutput.decision.updatedPermissions`; it does not change the session permission mode. +A session destination lasts for that CLI session; settings destinations write the scope named by +the original request. The UI does not claim the rule was applied merely because a file was written. + +Over the relay, `approvals.answer` retains `allow` and `deny` and adds +`{nodeId, pendingId, decision:"allow-always", suggestionIndex}`. The host rechecks saved node +ownership, the exact unresolved capability card, and the original request. Over direct SSH, the +phone reads that request and builds the same reply, refusing nodes owned on another SSH host. +An older host, a request with no eligible scope, or an unsupported response offers Open session; +there is no fallback to a guessed `2`. + +## Complete question answers + +A held question advertises `questionPendingId` and `questions`, including every question's full +text, header, option labels/descriptions and `multiSelect`. The supported schema is 1–4 questions +with 2–4 distinct options each. Duplicate question text, duplicate labels, malformed fields, +clipped/unsupported schema and incomplete selections are refused. + +Android renders radio choices for single-select questions and checkboxes for multi-select. Send +answers stays disabled until every question has at least one permitted selection. It sends +`questions.answer {nodeId, pendingId, selections:number[][]}`, where each inner list contains +option indexes for one question. Both host and direct-SSH builders rederive the exact labels from +the live original request. They preserve the complete original `tool_input`, including the original +`questions`, and add an `answers` object keyed by exact question text. Multi-select labels are +ordered by their original option positions and joined with comma-space. This sends all questions, +not only the first picker. + +When a v2 question is held, its published card omits legacy `options` and `multiSelect`, including +when the full schema cannot be answered. An older phone therefore offers Open session rather than +typing a digit before a picker exists. An unheld legacy question keeps its existing behavior: +measured single-choice quick keys, otherwise read-only choices and Open session. After a hold +expires, the CLI may display its ordinary picker; no premature legacy action is republished by +this v2 card. Free-text question entry and option-preview rendering are not added. + +## Ownership, races and outcomes + +New structured writers require a pending id belonging to the selected node. A fresh unresolved +card must still carry the same ticket and advertised rules/questions before the phone submits. +The host additionally rereads the original request. A missing or settled card never authorizes +input into a newer prompt. + +The local writer stages a unique private reply file, then rechecks the request's regular-file +identity, device/inode, modification time and size before atomic publication. SSH writers require a +regular nonsymlink request, bound its read, retain a checksum and stream the reply through stdin. +They recheck the request **after stdin completes and immediately before rename**, so an expiry or +replacement during a suspended transfer cannot publish a stale answer. Exact temporary files are +removed on refusal. The final check and rename remain separate operations; this is not a +transaction with the hook's poller. + +Writers report `sent`, `gone` or `failed`. SSH requires a confirmed exit status and expected success +output; a missing exit status or lost write acknowledgement is not success. An unanswered write is +never retried through another connection or replaced with keystrokes. Android handles a gone hold +as expired unless a fresh listing proves its own event resolved; unsupported responses open the +session. Per-host/event native admission blocks rapid duplicate taps while an action is pending. + +Android v2 rule/question cards settle when the managed hook actually consumes the reply and sends +its correlated POST. Legacy ordinary Approve/Deny and the separate structured chat answer path +retain their existing optimistic update. A consumed question +settles only its own question id; concurrent child permission tickets remain open. Existing boot +and hourly pending sweeps remove old orphan files after ten minutes. + +## Surfaces and verification limits + +Desktop routes local requests through its private writer and SSH-project requests through that +project's retained ControlMaster. Its standing relay uses those same writers. Server Edition's +canvas supports local remembered rules; its existing SSH-project refusal remains, and it does not +serve this legacy standing-phone relay. Direct SSH remains POSIX and writes only the selected +computer's own hook tickets. + +Android parses the additive mirror fields, implements both relay verbs and the SSH reply contract, +and exercises the real mirror producer/host router through its interop fixture. The bridges behind +relay fixture answer verbs record calls; they are not a live Claude process. Separate regressions +execute the shipped POSIX managed hook over real stdin/stdout and suspend real shell writers to +exercise timeout/replacement before publication. + +The installed Claude 2.1.289 public bundle and official schemas were inspected. That evidence does +not verify live CLI persistent-rule application, actual picker completion, an older CLI version or +new physical phone behavior. Those remain explicit follow-up checks. + +**iOS implication — @eneskirca:** adopt `permissionSuggestions`, `questionPendingId`, full `questions`, +`allow-always` plus `suggestionIndex`, `questions.answer` and the v2 SSH answer marker together. +Keep legacy `allow`/`deny`, ticket ownership, full-input preservation, post-stdin expiry checks and +unsupported-host degradation. Replace any blind Always allow digit with a request-owned rule; +held question cards no longer expose the old numbered choices. + +## Out of scope + - codex/gemini permission hooks (unverified decision contracts). - The desktop notch/HUD overlay (separate feature). diff --git a/docs/mobile-usage-inbox.md b/docs/mobile-usage-inbox.md index f544ca96db..2ca499ea06 100644 --- a/docs/mobile-usage-inbox.md +++ b/docs/mobile-usage-inbox.md @@ -29,6 +29,13 @@ export interface MirrorFile { } ``` +`settings.claudeAccounts[]` entries are `{ id, dir, label?, email? }`, built by +`mirrorClaudeAccount` in both shells and in the desktop's per-host SSH slice. `label` and `email` +are display-only, and absent when blank or on an older desktop. A reader names a managed account by +that `label`, else the `usage` row's label, else an email, else a short form of the id — never by +the raw id (Android: `AccountNames`). The `usage` block alone is not enough for this: it is +dropped from SSH slices, and it is empty until the first poll. + ### Usage ```ts diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index e2c4c658f3..4605216288 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -261,6 +261,18 @@ the hook. `persisted:false` ⇒ pin may survive, the UI must NOT show "Removed" retry; `killed:false` ⇒ the cut is unconfirmed. `peerId` is the peer's stable box public key (base64). +The shipped revoker is `createPeerRevoker` (`src/main/remote/peer-revoker.ts`): its hook +cuts the key's sessions on BOTH relay hosts, a peer desktop's (`killRelayHostsByPeerKey`) and +a phone's on the standing host (`killStandingHostSessionsByPeerKey`). Settings → Phone → +Revoke reaches the same revoker through pairing-service's `revokeRelayKey`, so forgetting a +phone also ends the relay session it has open (audit A07-revoke). That caller honours the +contract above: on `persisted:false` it puts the device back into `agent.json` and reports +`local:false` (relay `'unpin-failed'`), so Settings → Phone says "try again" instead of +"Removed" and Revoke retries the whole revoke; `killed:false` is reported as relay +`'cut-unconfirmed'`. The cut makes the phone redial at once, so the standing host remembers +every key revoked during the run and answers such a handshake the way Deny does, without a +dialog (`REVOKED_PHONE_DENY_MS`), unless the pin or a listed pairing lets it in first. + **Key-file codec** (pure, `src/main/remote/key-file-codec.ts` — no electron; `safeStorage` injected as `SafeStorageLike`): diff --git a/docs/uninstall.md b/docs/uninstall.md index c5886acfd8..b2eba93435 100644 --- a/docs/uninstall.md +++ b/docs/uninstall.md @@ -21,10 +21,10 @@ to apply. Interactive runs (downloaded script, no pipe) confirm before touching | Location | What it holds | Written by | | --- | --- | --- | -| `~/Library/Application Support/node-terminal` (macOS) / `~/.config/node-terminal` (Linux) | The Electron user-data dir: `workspace.json`, `settings.json`, generated `tmux.conf`, `terminal-scrollback/`, `hook-endpoint.env`, `context-links/`, `canvas-control/`, `claude-accounts//` (managed-account config dirs), `speech-models/`, media caches, secret stores, `device-id`, browser storage | everything behind `CorePlatform.userDataDir` | +| `~/Library/Application Support/node-terminal` (macOS) / `~/.config/node-terminal` (Linux), plus the legacy `…/nodeterm` spelling if present | The Electron user-data dir (named after package.json `name`, which is `node-terminal`): `workspace.json`, `settings.json`, generated `tmux.conf`, `terminal-scrollback/`, `hook-endpoint.env`, `context-links/`, `canvas-control/`, `claude-accounts//` (managed-account config dirs), `speech-models/`, media caches, secret stores, `device-id`, browser storage | everything behind `CorePlatform.userDataDir` | | `~/.nodeterm` | Machine-stable state shared by every instance: `agent-hooks/*.sh` (the managed hook scripts), `ssh-cm/*.sock` (SSH ControlMaster sockets), `acks/`, `pending/`, `push-grants/`, `cx/`, `codex-thread-names/`, the app-private ssh-agent | `install-helper.ts`, `control-master.ts`, `pairing-service.ts`, … | -| `~/Library/Caches/node-terminal`, `~/Library/Caches/node-terminal-updater`, `~/Library/Caches/com.nodeterm.app*`, `~/Library/Preferences/com.nodeterm.app.plist`, `~/Library/Saved Application State/…`, `~/Library/HTTPStorages/com.nodeterm.app`, `~/Library/Logs/node-terminal` (macOS) / `~/.cache/node-terminal`, `~/.cache/node-terminal-updater` (Linux) | Chromium/electron-updater caches, macOS window state | Electron / macOS | -| Keychain entry `node-terminal Safe Storage` (macOS) | The key Electron `safeStorage` encrypts local secrets with | Electron | +| `~/Library/Caches/node-terminal`, `~/Library/Caches/node-terminal-updater`, `~/Library/Caches/com.nodeterm.app*`, `~/Library/Preferences/com.nodeterm.app.plist`, `~/Library/Saved Application State/…`, `~/Library/HTTPStorages/com.nodeterm.app`, `~/Library/Logs/node-terminal` (macOS) / `~/.cache/node-terminal`, `~/.cache/node-terminal-updater` (Linux), plus legacy nodeterm cache/log locations | Chromium/electron-updater caches, macOS window state | Electron / macOS | +| Keychain entry `node-terminal Safe Storage` or legacy `nodeterm Safe Storage` (macOS) | The key Electron `safeStorage` encrypts local secrets with | Electron | | `/Applications/nodeterm.app` (or the Homebrew cask) | The app itself | `install.sh` / brew | | `~/.nodeterm-server-app`, `~/.nodeterm-server`, systemd units `nodeterm-server.service` + `nodeterm-server-update.{service,timer}` (system or `--user`) | Server Edition checkout, private Node runtime, data dir, service + daily auto-update timer | `install-server.sh` | diff --git a/docs/windows-session-host.md b/docs/windows-session-host.md index 5abf4f1dee..87788f1de9 100644 --- a/docs/windows-session-host.md +++ b/docs/windows-session-host.md @@ -165,6 +165,32 @@ long-lived connection (no positional-FIFO fragility, unlike this app's tmux cont shadow-client machinery exists to make cheap for tmux is simply not a problem here. See the `sendKeys`/`paneCommand`/`capture` rows above: none of them need an attached client. +### Explicit phone Send + +The v2 hello can negotiate `composed-input-v1` independently of other features. It adds +`prepareComposedV1`, `writeComposedV1` and `cancelComposedV1`; terminal protocol versions +and the public Android `pty.submitComposed` request/result are unchanged. Preparation mints +a one-use 10-second ticket scoped to the exact live host session generation and subscribed +socket. One lock spans paste and its separate Enter across clients. The host reads actual +emulator paste mode after its output barrier, then rechecks ownership before writing; Enter +also requires at least 150 ms since paste. Detach, cancellation and expiry only retire tickets. + +The client captures its subscriber registration/generation and original socket and checks them +inside the deferred send turn. A new socket cannot redeem an old ticket. Once paste may have +been transmitted, missing receipts and RPC errors return uncertain without replay or an Enter +on a replacement. Older live hosts refuse this extension while keeping their sessions alive. +Busy/stale attachment refusals do not tell users to upgrade. Direct non-persistent Windows PTYs +use the same composed writer through their captured `NativeWindowsPane` lifetime and emulator. +This action is distinct from the process-attested, observed-screen messaging extension below. +The minimum delay is between native write calls; ConPTY queues may still coalesce writes. +A positive result confirms terminal API submission, not application execution or physical +Windows delivery spacing under backpressure. + +The actual backend/client/emulator producer is exercised through Android's encrypted relay +and Kotlin API with a labeled native byte recorder. A separate Linux kernel-PTY component +proof exercises the real host/socket path. Physical Windows ConPTY and phone acceptance of +these additional backends remain pending; see the [Android checkpoint](android.md#composed-send-backend-follow-up-2026-10-07-a128). + ## The seeding trap (read this before touching `spawnNew`/`join`/`SessionHostPty`) CLAUDE.md's tmux section states the load-bearing rule for THAT backend: on a warm reattach, the diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 9abb45a436..a1aa5bfceb 100644 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -66,10 +66,17 @@ HOME="${HOME:-$(cd ~ && pwd)}" # electron-builder strips `build` from the packaged package.json, so "nodeterm" only ever names # the bundle and the installer. Must equal package.json `name` (scripts/uninstall.test.ts). APP_NAME="node-terminal" +# Electron names userData after package.json `name` ("node-terminal"): the top-level package.json +# has no `productName` (only `build.productName`, which electron-builder does not copy into the +# packaged package.json), so the installed app's dir is `node-terminal`, the same path the app's +# own hook shell walks (src/core/agents/hook-endpoint-failover-sh.ts). `nodeterm` is kept as a +# legacy location so an older layout is still cleaned up. if [ "$OS" = "Darwin" ]; then USER_DATA="$HOME/Library/Application Support/$APP_NAME" + LEGACY_USER_DATA="$HOME/Library/Application Support/nodeterm" else USER_DATA="${XDG_CONFIG_HOME:-$HOME/.config}/$APP_NAME" + LEGACY_USER_DATA="${XDG_CONFIG_HOME:-$HOME/.config}/nodeterm" fi NT_HOME="$HOME/.nodeterm" # agent-hooks, ssh-cm sockets, acks, push-grants… SERVER_APP="${NODETERM_APP_DIR:-$HOME/.nodeterm-server-app}" @@ -362,8 +369,10 @@ DIRS_TO_REMOVE=() add_dir() { [ -e "$1" ] && { DIRS_TO_REMOVE+=("$1"); plan "Delete $1"; FOUND_ANY=1; }; } add_dir "$NT_HOME" add_dir "$USER_DATA" +add_dir "$LEGACY_USER_DATA" if [ "$OS" = "Darwin" ]; then add_dir "$HOME/Library/Caches/$APP_NAME" + add_dir "$HOME/Library/Caches/nodeterm" add_dir "$HOME/Library/Caches/com.nodeterm.app" add_dir "$HOME/Library/Caches/com.nodeterm.app.ShipIt" add_dir "$HOME/Library/Caches/$APP_NAME-updater" @@ -371,9 +380,11 @@ if [ "$OS" = "Darwin" ]; then add_dir "$HOME/Library/Saved Application State/com.nodeterm.app.savedState" add_dir "$HOME/Library/HTTPStorages/com.nodeterm.app" add_dir "$HOME/Library/Logs/$APP_NAME" + add_dir "$HOME/Library/Logs/nodeterm" else add_dir "${XDG_CACHE_HOME:-$HOME/.cache}/$APP_NAME" add_dir "${XDG_CACHE_HOME:-$HOME/.cache}/$APP_NAME-updater" + add_dir "${XDG_CACHE_HOME:-$HOME/.cache}/nodeterm" fi add_dir "$SERVER_APP" add_dir "$SERVER_DATA" @@ -386,14 +397,16 @@ if [ "$OS" = "Darwin" ]; then elif [ -d "$APP_BUNDLE" ]; then plan "Delete $APP_BUNDLE"; FOUND_ANY=1 fi - if security find-generic-password -s "$APP_NAME Safe Storage" >/dev/null 2>&1; then - plan "Delete the '$APP_NAME Safe Storage' Keychain entry"; FOUND_ANY=1 - fi + for kc in "nodeterm Safe Storage" "$APP_NAME Safe Storage"; do + if security find-generic-password -s "$kc" >/dev/null 2>&1; then + plan "Delete the '$kc' Keychain entry"; FOUND_ANY=1 + fi + done fi # -- per-project data: enumerate BEFORE the workspace index is deleted; never auto-deleted. PROJECT_DIRS="" -for ws in "$USER_DATA/workspace.json" "$SERVER_DATA/workspace.json"; do +for ws in "$USER_DATA/workspace.json" "$LEGACY_USER_DATA/workspace.json" "$SERVER_DATA/workspace.json"; do [ -f "$ws" ] || continue cwds="$(jsedit list-projects "$ws" 2>/dev/null || true)" while IFS= read -r cwd; do @@ -513,6 +526,7 @@ if [ "$OS" = "Darwin" ]; then rm -rf "$APP_BUNDLE" && ok "Deleted $APP_BUNDLE" \ || warn "Could not delete $APP_BUNDLE — drag it to the Trash" fi + security delete-generic-password -s "nodeterm Safe Storage" >/dev/null 2>&1 || true security delete-generic-password -s "$APP_NAME Safe Storage" >/dev/null 2>&1 || true defaults delete com.nodeterm.app >/dev/null 2>&1 || true fi diff --git a/src/core/ack-sweep.test.ts b/src/core/ack-sweep.test.ts index 358970a360..eac72df46b 100644 --- a/src/core/ack-sweep.test.ts +++ b/src/core/ack-sweep.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { sweepAckDir, createAckSweeper, type AckSweepFsLike } from './ack-sweep' +import { sweepAckDir, createAckSweeper, remoteAckSweepInput, type AckSweepFsLike } from './ack-sweep' /** * In-memory fs double for the sweeper. `dir` is a flat map of name → content; `mtime` advances on @@ -9,10 +9,12 @@ function fakeFs(initial: Record) { const files = new Map(Object.entries(initial)) let mtimeMs = 1 const readErrors = new Set() + const reads: string[] = [] const fs: AckSweepFsLike = { readdirSync: (_dir) => [...files.keys()], readFileSync: (p, _e) => { const name = p.split('/').pop() as string + reads.push(name) if (readErrors.has(name)) throw new Error('EACCES') if (!files.has(name)) throw new Error('ENOENT') return files.get(name) as string @@ -26,6 +28,7 @@ function fakeFs(initial: Record) { return { fs, files, + reads, setMtime: (n: number) => (mtimeMs = n), getMtime: () => mtimeMs, failRead: (name: string) => readErrors.add(name), @@ -45,6 +48,7 @@ describe('sweepAckDir — consume + resolve + delete', () => { const acked: string[] = [] const cleared: string[] = [] const consumed = sweepAckDir(DIR, f.fs, { + ownsNode: () => true, ackDone: (id) => acked.push(id), onUnreadClear: (id) => cleared.push(id) }) @@ -64,6 +68,7 @@ describe('sweepAckDir — consume + resolve + delete', () => { }) const acked: string[] = [] const consumed = sweepAckDir(DIR, f.fs, { + ownsNode: () => true, ackDone: (id) => acked.push(id), onUnreadClear: () => {} }) @@ -77,12 +82,12 @@ describe('sweepAckDir — consume + resolve + delete', () => { const f = fakeFs({ 'nt-a.seen': 'evt-1', 'nt-b.seen': 'evt-2' }) f.failRead('nt-a.seen') const acked: string[] = [] - const first = sweepAckDir(DIR, f.fs, { ackDone: (id) => acked.push(id), onUnreadClear: () => {} }) + const first = sweepAckDir(DIR, f.fs, { ownsNode: () => true, ackDone: (id) => acked.push(id), onUnreadClear: () => {} }) expect(first).toEqual(['nt-b']) // a was unreadable this pass, skipped (still on disk) expect(f.files.has('nt-a.seen')).toBe(true) // The write finishes (file readable) — next pass consumes it. f.clearReadError('nt-a.seen') - const second = sweepAckDir(DIR, f.fs, { ackDone: (id) => acked.push(id), onUnreadClear: () => {} }) + const second = sweepAckDir(DIR, f.fs, { ownsNode: () => true, ackDone: (id) => acked.push(id), onUnreadClear: () => {} }) expect(second).toEqual(['nt-a']) expect(acked.sort()).toEqual(['nt-a', 'nt-b']) expect(f.files.size).toBe(0) @@ -97,13 +102,14 @@ describe('sweepAckDir — consume + resolve + delete', () => { statSync: () => ({ mtimeMs: 1 }), rmSync: () => {} } - expect(sweepAckDir(DIR, fs, { ackDone: () => {}, onUnreadClear: () => {} })).toEqual([]) + expect(sweepAckDir(DIR, fs, { ownsNode: () => true, ackDone: () => {}, onUnreadClear: () => {} })).toEqual([]) }) it('a throwing ackDone still clears unread and deletes the file (never re-ack loop)', () => { const f = fakeFs({ 'nt-a.seen': 'evt-1' }) const cleared: string[] = [] const consumed = sweepAckDir(DIR, f.fs, { + ownsNode: () => true, ackDone: () => { throw new Error('boom') }, @@ -123,7 +129,7 @@ describe('createAckSweeper — dir-mtime gate + no re-ack loop', () => { const sweeper = createAckSweeper({ dir: DIR, fs: f.fs, - handlers: { ackDone: (id) => acked.push(id), onUnreadClear: (id) => cleared.push(id) } + handlers: { ownsNode: () => true, ackDone: (id) => acked.push(id), onUnreadClear: (id) => cleared.push(id) } }) // First sweep: dir mtime (1) differs from the initial -1 → scan + consume. expect(sweeper.sweep()).toEqual(['nt-a']) @@ -146,7 +152,7 @@ describe('createAckSweeper — dir-mtime gate + no re-ack loop', () => { dir: DIR, fs: f.fs, intervalMs: 10, - handlers: { ackDone: (id) => acked.push(id), onUnreadClear: () => {} } + handlers: { ownsNode: () => true, ackDone: (id) => acked.push(id), onUnreadClear: () => {} } }) sweeper.start() sweeper.start() // idempotent — no second timer @@ -154,3 +160,70 @@ describe('createAckSweeper — dir-mtime gate + no re-ack loop', () => { expect(acked).toEqual([]) }) }) + + +describe('ack ownership across desktops', () => { + it('leaves another owner’s ack unread so either sweep order delivers it to its owner', () => { + for (const order of [['local', 'driver'], ['driver', 'local']]) { + const f = fakeFs({ 'local.seen': 'event-local', 'driver.seen': 'event-driver' }) + const acked: string[] = [] + const cleared: string[] = [] + for (const owner of order) { + expect(sweepAckDir(DIR, f.fs, { + ownsNode: (id) => id === owner, + ackDone: (id) => acked.push(id), + onUnreadClear: (id) => cleared.push(id) + })).toEqual([owner]) + expect(f.reads).toEqual(acked.map((id) => id + '.seen')) + } + expect(acked).toEqual(order) + expect(cleared).toEqual(order) + expect(f.files.size).toBe(0) + } + }) + + it('fails closed when the ownership lookup throws, before reading the ack', () => { + const f = fakeFs({ 'foreign.seen': 'event-foreign' }) + expect(sweepAckDir(DIR, f.fs, { + ownsNode: () => { throw new Error('ownership not loaded') }, + ackDone: () => { throw new Error('must not ack') }, + onUnreadClear: () => { throw new Error('must not clear') } + })).toEqual([]) + expect(f.reads).toEqual([]) + expect(f.files.get('foreign.seen')).toBe('event-foreign') + }) + + it('consumes a retained ack when ownership is learned without a directory write', () => { + const f = fakeFs({ 'later.seen': 'event-later' }) + const owned = new Set() + const acked: string[] = [] + const sweeper = createAckSweeper({ dir: DIR, fs: f.fs, handlers: { + ownsNode: (id) => owned.has(id), ackDone: (id) => acked.push(id), onUnreadClear: () => {} + } }) + expect(sweeper.sweep()).toEqual([]) + expect(f.reads).toEqual([]) + const mtime = f.getMtime() + owned.add('later') + expect(f.getMtime()).toBe(mtime) + expect(sweeper.sweep()).toEqual(['later']) + expect(acked).toEqual(['later']) + expect(sweeper.sweep()).toEqual([]) + }) + + it('retries a retained unreadable owned file without another directory write', () => { + const f = fakeFs({ 'local.seen': 'event-local' }) + f.failRead('local.seen') + const sweeper = createAckSweeper({ dir: DIR, fs: f.fs, handlers: { + ownsNode: () => true, ackDone: () => {}, onUnreadClear: () => {} + } }) + expect(sweeper.sweep()).toEqual([]) + f.clearReadError('local.seen') + expect(sweeper.sweep()).toEqual(['local']) + }) + + it('keeps remote ownership as deduplicated safe stdin data', () => { + expect(remoteAckSweepInput(['a', 'b_2', 'a', '../foreign', 'x\nother', 'x'.repeat(129)])) + .toBe('a\nb_2\n') + expect(remoteAckSweepInput([])).toBe('') + }) +}) diff --git a/src/core/ack-sweep.ts b/src/core/ack-sweep.ts index 54710689c3..17b8a35d30 100644 --- a/src/core/ack-sweep.ts +++ b/src/core/ack-sweep.ts @@ -19,6 +19,7 @@ import fs from 'fs' import os from 'os' import path from 'path' +import { isSafeNodeId } from '../shared/safe-id' /** The slice of `fs` the sweeper needs — injectable so tests drive it without touching disk. */ export interface AckSweepFsLike { @@ -43,6 +44,8 @@ export function defaultAckDir(): string { const ACK_EXT = '.seen' export interface AckSweepHandlers { + /** Positive ownership in this mirror. Another desktop's ack must remain unread and untouched. */ + ownsNode(nodeId: string): boolean /** Resolve the node's done inbox event(s) + fire the end seam (the core mirror's `ackDone`). */ ackDone(nodeId: string): void /** Drop the renderer's unread flag for the node. MUST NOT re-ack (external, non-looping clear). */ @@ -50,7 +53,7 @@ export interface AckSweepHandlers { } /** - * One pass over `dir`: for each `.seen` file, run `ackDone` + `onUnreadClear`, then delete + * One pass over `dir`: for each owned `.seen` file, run `ackDone` + `onUnreadClear`, then delete * the file. Returns the node ids it consumed. Tolerant at every step: a missing dir, an * unreadable/unstattable file, or an odd name are skipped silently (a half-written file simply * isn't consumed until it is complete, and re-appears next pass). The file content (the acked event @@ -58,7 +61,12 @@ export interface AckSweepHandlers { * newest unresolved done regardless (a stray/duplicate ack is an idempotent no-op in the mirror). * Pure apart from the injected handlers + the delete. */ -export function sweepAckDir(dir: string, fsi: AckSweepFsLike, handlers: AckSweepHandlers): string[] { +export function sweepAckDir( + dir: string, + fsi: AckSweepFsLike, + handlers: AckSweepHandlers, + onRetained?: () => void +): string[] { let names: string[] try { names = fsi.readdirSync(dir) @@ -71,12 +79,23 @@ export function sweepAckDir(dir: string, fsi: AckSweepFsLike, handlers: AckSweep if (!name.endsWith(ACK_EXT)) continue const nodeId = name.slice(0, -ACK_EXT.length) if (!nodeId) continue + let owned = false + try { + owned = handlers.ownsNode(nodeId) === true + } catch { + // A failed ownership lookup is not permission to consume another mirror's ack. + } + if (!owned) { + onRetained?.() + continue + } const full = path.join(dir, name) // Read the content as a completeness gate: an unreadable file (transient / mid-write) is skipped // and retried next pass. The value itself (the event id) is informational — see the docblock. try { fsi.readFileSync(full, 'utf8') } catch { + onRetained?.() continue } // A handler must never break the sweep (or leave the file un-consumed): ack + clear, then delete. @@ -93,6 +112,7 @@ export function sweepAckDir(dir: string, fsi: AckSweepFsLike, handlers: AckSweep try { fsi.rmSync(full, { force: true }) } catch { + onRetained?.() // Couldn't delete (rare): re-processing next pass is harmless — ackDone/clearUnread are both // idempotent no-ops once the node has no unresolved done / no unread. } @@ -114,8 +134,8 @@ export interface AckSweeperOpts { } export interface AckSweeper { - /** Sweep once, gated by a cheap dir-mtime check (skips the readdir when nothing changed since the - * last sweep). Returns the node ids consumed this pass. */ + /** Sweep once. A cheap dir-mtime check skips readdir after a fully consumed pass; retained + * acks are retried so a newly learned owner can consume an existing file. Returns consumed ids. */ sweep(): string[] /** Begin sweeping on the interval. Idempotent. */ start(): void @@ -125,8 +145,8 @@ export interface AckSweeper { /** * A self-scheduling ack sweeper for the mirror-owning process. `sweep()` re-reads the directory only - * when its mtime changed since the last pass (a `.seen` added, or our own delete) — the cheap gate the - * spec calls for, matching push-grants. Fails open: any fs error yields "nothing consumed", never a + * when its mtime changed since the last fully consumed pass. A retained ack disables this cache: + * ownership can change without a filesystem write. Any fs error yields "nothing consumed", never a * throw. `start()`/`stop()` drive it on a 15s interval (the desktop shell drives its own combined * local+remote cadence, so it uses `sweep()` directly; the server shell just calls `start()`). */ @@ -151,9 +171,14 @@ export function createAckSweeper(opts: AckSweeperOpts): AckSweeper { // (dirMtime === -1) this also skips when the dir doesn't exist yet (dm === -1), which is correct // — there is nothing to consume. A first `.seen` drop moves the mtime off -1 and triggers a scan. if (dm === dirMtime) return [] - const consumed = sweepAckDir(dir, fsi, opts.handlers) + let retained = false + const consumed = sweepAckDir(dir, fsi, opts.handlers, () => { + retained = true + }) // Our deletes moved the mtime again — re-capture so the next tick re-scans only on a NEW drop. - dirMtime = dirMtimeMs() + // Learning a node from a hook or restored project does not change this directory's mtime. + // Retry retained acks until their owner consumes them, even without another phone write. + dirMtime = retained ? -1 : dirMtimeMs() return consumed } @@ -172,3 +197,20 @@ export function createAckSweeper(opts: AckSweeperOpts): AckSweeper { } } } + +/** A remote sweep receives ONLY this desktop's owned node ids on stdin, one per line. Never glob + * the shared ack directory: a host may also run another nodeterm whose files must remain intact. */ +export const REMOTE_ACK_SWEEP_CMD = + 'd="$HOME/.nodeterm/acks"; [ -d "$d" ] || exit 0; ' + + 'while IFS= read -r id; do ' + + "case \"$id\" in ''|*[!a-zA-Z0-9_-]*) continue ;; esac; " + + '[ "${#id}" -le 128 ] || continue; ' + + 'f="$d/$id.seen"; [ -f "$f" ] || continue; ' + + 'cat "$f" >/dev/null 2>&1 || continue; ' + + 'rm -f "$f" || continue; printf "%s\\n" "$id"; done' + +/** Data stays on stdin rather than in the shell command, including large canvases. */ +export function remoteAckSweepInput(nodeIds: Iterable): string { + const ids = [...new Set(nodeIds)].filter(isSafeNodeId) + return ids.length ? ids.join('\n') + '\n' : '' +} diff --git a/src/core/agent-status-mirror.test.ts b/src/core/agent-status-mirror.test.ts index 5382366e8c..408b2b305c 100644 --- a/src/core/agent-status-mirror.test.ts +++ b/src/core/agent-status-mirror.test.ts @@ -2,8 +2,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import fs from 'fs' import os from 'os' import path from 'path' -import { normalizeClaude, type NormalizedAgentEvent } from '@shared/agents/normalize' +import { normalizeClaude, normalizeCodex, type NormalizedAgentEvent } from '@shared/agents/normalize' import { syntheticAnsweredEvent } from './agents/pending-approvals' +import { createAckSweeper } from './ack-sweep' import { reduceEntry, buildFile, @@ -14,9 +15,11 @@ import { recordContextUsage, clearNode, ackDone, + mirrorOwnsNode, flush, initAgentStatusMirror, setMirrorSettingsProvider, + mirrorClaudeAccount, setNodeHibernated, setMirrorServerProvider, setMirrorUsageProvider, @@ -312,6 +315,66 @@ describe('settings block', () => { }) }) +// The phone names a managed account from this entry (audit A39/A75): without `label`/`email` it had +// only the id, and every picker row and session row printed the account's raw UUID. +describe('settings.claudeAccounts entries (mirrorClaudeAccount)', () => { + let tmpDir: string + + beforeEach(() => { + _resetForTest() + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-status-')) + }) + + afterEach(() => { + _resetForTest() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('carries the label and email a phone shows beside the id and dir it launches with', () => { + expect( + mirrorClaudeAccount({ id: 'a1', label: 'Work', email: 'me@work.example' }, '/data/claude-accounts/a1') + ).toEqual({ id: 'a1', dir: '/data/claude-accounts/a1', label: 'Work', email: 'me@work.example' }) + }) + + it('trims, and leaves out a blank or wrong-typed value from a hand-edited settings.json', () => { + expect(mirrorClaudeAccount({ id: 'a1', label: ' Work ', email: '' }, '/d')).toEqual({ + id: 'a1', + dir: '/d', + label: 'Work' + }) + expect(mirrorClaudeAccount({ id: 'a1', label: 123, email: { x: 1 } }, '/d')).toEqual({ id: 'a1', dir: '/d' }) + // No settings-only field (host, configDir, color, createdAt…) leaks into the mirror. + const acct = { id: 'a1', label: 'L', host: 'u@h', configDir: '/x', color: '#fff', createdAt: 1 } + expect(Object.keys(mirrorClaudeAccount(acct, '/d')).sort()).toEqual(['dir', 'id', 'label']) + }) + + it('rides the flushed file unchanged', async () => { + const file = path.join(tmpDir, 'status.json') + initAgentStatusMirror(file) + setMirrorSettingsProvider(() => ({ + claudeAccounts: [mirrorClaudeAccount({ id: 'a1', label: 'Work', email: 'me@work.example' }, '/d/a1')] + })) + await flush() + expect(JSON.parse(fs.readFileSync(file, 'utf-8')).settings.claudeAccounts).toEqual([ + { id: 'a1', dir: '/d/a1', label: 'Work', email: 'me@work.example' } + ]) + }) + + // Both shells and the desktop's per-host SSH slice publish this list. A site that went back to an + // inline `{ id, dir }` would type-check (the display half is optional) and pass every other test, + // while its phones fell back to printing ids — so every producer is pinned to the one builder. + it('every producer of the list builds its entries with mirrorClaudeAccount', () => { + const root = path.resolve(__dirname, '../..') + const sites: Record = { 'src/main/index.ts': 2, 'src/server/index.ts': 1 } + for (const [rel, expected] of Object.entries(sites)) { + const src = fs.readFileSync(path.join(root, rel), 'utf8').replace(/\r\n/g, '\n') + const producers = [...src.matchAll(/claudeAccounts: \(s\.claudeAccounts \?\? \[\]\)([\s\S]*?)\.map\(\(a\) => ([^\n]*)/g)] + expect(producers.length, `${rel}: producer count`).toBe(expected) + for (const m of producers) expect(m[2], `${rel}: ${m[0]}`).toMatch(/^mirrorClaudeAccount\(a, /) + } + }) +}) + // ---- server-update (install metadata block) ------------------------------------------------- describe('server block', () => { @@ -2515,6 +2578,37 @@ describe('hibernated flag (Eco × phone — SLEEPING on external readers)', () = expect(_snapshot().n1.state).toBeUndefined() expect(_snapshot().n1.hibernated).toBe(true) }) + + // The A76 review: the phone offers to type a wake line off this flag, so a flag left standing on + // a running CLI is a resume line typed into that CLI's composer. The renderer drops its own copy + // on these edges; the mirror hears the same hook events (with or without a renderer) and does too. + it('a live state drops the flag — including a codex SessionStart, which arrives as `working`', () => { + for (const state of ['working', 'blocked', 'waiting'] as const) { + recordAgentEvent(ev({ nodeId: 'n1', state: 'done', sessionId: 's1' })) + setNodeHibernated('n1', true) + recordAgentEvent(ev({ nodeId: 'n1', state, sessionId: 's1', newTurn: true })) + expect(_snapshot().n1.hibernated, state).toBeUndefined() + } + // The resume the phone types into a Sleeping codex pane, through the real normalizer: codex + // maps SessionStart to a state, never to the session start Canvas also clears on. + setNodeHibernated('cx', true) + const start = normalizeCodex({ nodeId: 'cx', agentId: 'codex', payload: { hook_event_name: 'SessionStart', session_id: 't1' } }) + expect(start?.kind).toBe('state') + recordAgentEvent(start!) + expect(_snapshot().cx.hibernated).toBeUndefined() + }) + + it('a session START drops the flag; `done` and a session END leave it', () => { + recordAgentEvent(ev({ nodeId: 'n1', state: 'done', sessionId: 's1' })) + setNodeHibernated('n1', true) + // A late Stop after the /exit must not undo the hibernation just performed. + recordAgentEvent(ev({ nodeId: 'n1', state: 'done', sessionId: 's1' })) + recordAgentEvent(ev({ nodeId: 'n1', kind: 'session', sessionPhase: 'end', sessionId: 's1' })) + expect(_snapshot().n1.hibernated).toBe(true) + // A claude/gemini/grok resume starts its session: a CLI is in the pane again. + recordAgentEvent(ev({ nodeId: 'n1', kind: 'session', sessionPhase: 'start', sessionId: 's1' })) + expect(_snapshot().n1.hibernated).toBeUndefined() + }) }) // ---- The observed Claude account (which account a RUNNING session is on) --------------------- @@ -2594,6 +2688,59 @@ describe('MirrorEntry.account (observed Claude account)', () => { }) }) + +describe('read-ack ownership from the own persisted mirror', () => { + let dir: string + beforeEach(() => { + _resetForTest() + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ack-ownership-')) + }) + afterEach(() => { + _resetForTest() + fs.rmSync(dir, { recursive: true, force: true }) + }) + + it('owns an expired node’s unresolved done card until it is consumed', () => { + const old = Date.now() - EXPIRE_MS - 1 + const file = path.join(dir, 'agent-status.json') + fs.writeFileSync(file, JSON.stringify({ + nodes: { own: { state: 'done', updatedAt: old } }, + inbox: { events: [{ id: 'old-1', nodeId: 'own', kind: 'done', title: 'Finished', ts: old }], nodes: {} } + })) + // Another desktop's project slice is on the same host but is never this mirror's input. + fs.writeFileSync(path.join(dir, 'agent-status-foreign-project.json'), JSON.stringify({ + nodes: { foreign: { state: 'done', updatedAt: Date.now() } } + })) + initAgentStatusMirror(file) + expect(_snapshot().own).toBeUndefined() + expect(mirrorOwnsNode('own')).toBe(true) + expect(mirrorOwnsNode('foreign')).toBe(false) + const ackDir = path.join(dir, 'acks') + fs.mkdirSync(ackDir) + fs.writeFileSync(path.join(ackDir, 'own.seen'), 'old-1') + fs.writeFileSync(path.join(ackDir, 'foreign.seen'), 'foreign-1') + const cleared: string[] = [] + const sweeper = createAckSweeper({ dir: ackDir, handlers: { + ownsNode: mirrorOwnsNode, ackDone, onUnreadClear: (id) => cleared.push(id) + } }) + expect(sweeper.sweep()).toEqual(['own']) + expect(cleared).toEqual(['own']) + expect(fs.existsSync(path.join(ackDir, 'own.seen'))).toBe(false) + expect(fs.readFileSync(path.join(ackDir, 'foreign.seen'), 'utf8')).toBe('foreign-1') + expect(_inboxSnapshot().events[0].resolved).toBe(true) + expect(mirrorOwnsNode('own')).toBe(false) + }) + + it('owns a live node and relinquishes its resolved inbox history after removal', () => { + recordAgentEvent(ev({ nodeId: 'own', state: 'working', newTurn: true })) + recordAgentEvent(ev({ nodeId: 'own', state: 'done', lastMessage: 'Finished' })) + expect(mirrorOwnsNode('own')).toBe(true) + ackDone('own') + clearNode('own') + expect(mirrorOwnsNode('own')).toBe(false) + }) +}) + // The phone's `agent.answer` binds a pendingId to the node it names (main/remote/host-chat.ts): a // ticket the mirror recorded for THIS node passes, one recorded for another node — or one it has // since resolved — does not. diff --git a/src/core/agent-status-mirror.ts b/src/core/agent-status-mirror.ts index c606fc7cea..49d1c69b06 100644 --- a/src/core/agent-status-mirror.ts +++ b/src/core/agent-status-mirror.ts @@ -7,6 +7,7 @@ import type { AgentId } from '@shared/agents/config' import type { AgentState, NormalizedAgentEvent } from '@shared/agents/normalize' import type { ObservedClaudeAccount } from '@shared/types' import { WORKING_STALE_MS, isStaleWorking } from '@shared/agents/stale' +import { hookQuestions, type HookQuestion, type PermissionSuggestion } from '@shared/hook-answers' import { parseIdentitySeed } from '@shared/agent-identity-seed' /** @@ -54,6 +55,9 @@ export const EXPIRE_MS = 6 * 60 * 60_000 export const IDENTITY_EXPIRE_MS = 30 * 24 * 60 * 60_000 // Coalesce bursty hook POSTs (a single turn fires many tool events) into one disk write. export const WRITE_DEBOUNCE_MS = 300 +/** The mirror's file name under userData. Read back by the `projects.list` blob + * (core/projects-list-blob.ts) and, over SSH, by the phones themselves. */ +export const AGENT_STATUS_FILE = 'agent-status.json' export interface MirrorEntry { /** Tickets introduced concurrently with a picker, retained until reply or explicit reset. */ @@ -155,6 +159,12 @@ export interface MirrorEntry { * render SLEEPING instead of an unexplained idle shell (`setNodeHibernated`). Present = true, * absent = not hibernated — like `restored`/`idleInferred`, so old files keep their shape. * + * The one thing the mirror decides for itself is the renderer's own self-heal: a live state + * (working/blocked/waiting) or a session START recorded for the node drops the flag + * (`reduceEffectiveEntry`). The phone offers to type a wake line off this flag, so a stale one is + * not cosmetic, and the mirror hears every hook event even when no renderer does (a Server + * Edition with no browser tab, a reloading window). A codex SessionStart arrives as `working`. + * * A hibernated entry is EXEMPT from the expiry sweep: hibernation is precisely "idle for hours", * so the 6 h staleness rule would erase the one durable fact this field exists to carry. The * renderer re-reports its persisted set at boot, and a wake (or `clearNode`) drops the flag. @@ -216,8 +226,9 @@ export interface MirrorSettings { * on it only for claude (the desktop does exactly that — activePermissionMode in * renderer/state/permissionMode.ts). */ autoSupported?: boolean - /** Managed accounts usable on THIS host; dirs are absolute on that host. */ - claudeAccounts?: { id: string; dir: string }[] + /** Managed accounts usable on THIS host; dirs are absolute on that host. Build every entry with + * `mirrorClaudeAccount` — both shells and the SSH slice do — so the display half cannot drift. */ + claudeAccounts?: MirrorClaudeAccount[] /** * Which values THIS host's `codex` accepts for `--ask-for-approval`, read from its own `--help`. * Absent = not probed / not knowable for this host, which means "use `on-request` and `never` @@ -267,6 +278,42 @@ export interface MirrorCustomAgent { binaries: string[] } +/** + * One managed Claude account as the mirror advertises it. `id` + `dir` are what a phone launches + * with (`CLAUDE_CONFIG_DIR`); `label` + `email` are what it SHOWS, so a picker or a session row + * never has to print the account's raw UUID. The `usage` block also carries a label per account, + * but it is local-only (dropped from SSH slices) and empty until the first usage poll — so the + * name has to ride here too. + * + * `label` and `email` are additive (absent on files written before them; old readers ignore + * them) and absent when blank. + */ +export interface MirrorClaudeAccount { + id: string + /** Absolute on the host whose mirror this is. */ + dir: string + /** The account's display label from settings (Settings → Accounts; defaults to its email). */ + label?: string + /** The login email captured when the account signed in. */ + email?: string +} + +/** + * Build one `MirrorSettings.claudeAccounts` entry. The ONE definition, used by the desktop shell, + * the Server Edition and the desktop's per-host SSH slice. `label`/`email` come out of a + * hand-editable settings.json that nothing checks field by field on load, so each is re-validated + * as a string here and a blank or wrong-typed value is simply left out. + */ +export function mirrorClaudeAccount( + account: { id: string; label?: unknown; email?: unknown }, + dir: string +): MirrorClaudeAccount { + const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '') + const label = text(account.label) + const email = text(account.email) + return { id: account.id, dir, ...(label ? { label } : {}), ...(email ? { email } : {}) } +} + export interface MirrorFile { v: 1 updatedAt: number @@ -464,6 +511,9 @@ export interface InboxEvent { * `~/.nodeterm/pending/.answer` to answer it. Rides the mirror to the phone; dropped * from the push-notify body (the phone re-reads the mirror before acting). Absent = legacy prompt. */ pendingId?: string + permissionSuggestions?: PermissionSuggestion[] + questionPendingId?: string + questions?: HookQuestion[] } export interface InboxNodeNow { /** ≤80 chars — "Editing foo.ts", "Running npm test", "Reading bar.ts". */ @@ -581,6 +631,11 @@ function reduceEffectiveEntry( // it was. `restored` means "this state came off disk", not "we have heard something since // boot", and gate 2 will read it as the former. delete next.restored + // A LIVE state is the CLI reporting from inside the pane, so "its CLI was exited" (Eco's + // `hibernated`) no longer holds — the renderer's own self-heal (`agentStatus.setState`), + // applied here too. Never on `done` (a late Stop must not undo a hibernation just performed) + // and never on a stateless commit (the /exit's own SessionEnd lands as one). + if (state === 'working' || state === 'blocked' || state === 'waiting') delete next.hibernated } // Unrelated tool hooks (including untagged child hooks) are not answers. Keep both // the state and its original evidence/identity until a correlated result or explicit reset. @@ -685,6 +740,9 @@ function reduceEffectiveEntry( // and is what makes a refusal retryable. commitState(undefined, false) next.awaitingInput = undefined + // A START is a CLI launching in that pane (Canvas's session-start clear, applied here too); + // an END is what the hibernating /exit itself fires, so it must leave the flag alone. + if (ev.sessionPhase === 'start') delete next.hibernated delete next.turnId // The boundary proves nothing about a state (and leaves `verifiedAt` alone), but a VERIFIED // start arms the idle rescue above. @@ -1142,6 +1200,8 @@ function fireNodeNowChange(c: NodeNowChange): void { // time-guarded (STASH_MAX_AGE_MS) so a stash that was never consumed (e.g. a picker that // auto-resolved) can't be picked up by a genuinely unrelated later needs-you in the same turn. interface QuestionStash { + questionPendingId?: string + questions?: HookQuestion[] /** AskUserQuestion picker: ≤4 labels, each ≤60 chars. Its PRESENCE marks a real question. */ options?: string[] /** The first question's prompt text, clipped to INBOX_TITLE_MAX. Absent if not present/parseable. */ @@ -1598,7 +1658,7 @@ function loadPersisted(file: string): void { * field bug). */ export function initAgentStatusMirror(filePath?: string): void { - targetFile = filePath ?? path.join(platform().userDataDir, 'agent-status.json') + targetFile = filePath ?? path.join(platform().userDataDir, AGENT_STATUS_FILE) loadPersisted(targetFile) startStaleSweep() } @@ -1606,7 +1666,7 @@ export function initAgentStatusMirror(filePath?: string): void { function resolveFile(): string | null { if (targetFile) return targetFile try { - targetFile = path.join(platform().userDataDir, 'agent-status.json') + targetFile = path.join(platform().userDataDir, AGENT_STATUS_FILE) return targetFile } catch { return null @@ -1796,6 +1856,13 @@ function produceInboxFromState( // the picker directly. That held PermissionRequest (if any) simply times out after 45s and the // picker shows anyway (acceptable). See docs/hook-reply-approvals.md. const pendingId = kind === 'approval' ? ev.pendingId : undefined + const questionPendingId = kind === 'question' ? stash?.questionPendingId : undefined + const questions = questionPendingId ? stash?.questions : undefined + const permissionSuggestions = kind === 'approval' ? ev.permissionSuggestions : undefined + // Old phones must not type numbered keys before this held picker is painted. The full v2 + // schema is additive; an old consumer sees Open session until the hook releases/times out. + const legacyOptions = questionPendingId ? undefined : options + const legacyMultiSelect = questionPendingId ? undefined : multiSelect // needsYou live-update on the EDGE into the needs-you state (a re-assert of the SAME state // keeps the activity live). Carries the classified kind + options (question) / pendingId // (approval) so the Live Activity renders straight from this same code path @@ -1813,7 +1880,8 @@ function produceInboxFromState( ? inboxEvents.find(e => e.nodeId === nodeId && e.kind === 'approval' && !e.resolved && e.pendingId === ev.pendingId) : newestUnresolved(inboxEvents.filter(e => !(e.kind === 'approval' && e.pendingId && keepApprovalIds.includes(e.pendingId))), nodeId) - const sameTitle = !!dup && dup.title === title + const sameTitle = !!dup && dup.title === title && + (!questionPendingId || dup.questionPendingId === questionPendingId) const freshDup = sameTitle && dup ? now - dup.ts < QUESTION_DEDUP_WINDOW_MS : false const newAsk = !freshDup // The needs-you live-update fires on the edge INTO needs-you — and also whenever the ASK @@ -1829,8 +1897,8 @@ function produceInboxFromState( state: 'needsYou', kind, message: headline, - ...(options ? { options } : {}), - ...(multiSelect ? { multiSelect: true } : {}), + ...(legacyOptions ? { options: legacyOptions } : {}), + ...(legacyMultiSelect ? { multiSelect: true } : {}), ...(pendingId ? { pendingId } : {}) }) } @@ -1844,9 +1912,11 @@ function produceInboxFromState( kind, title, ...(detail ? { detail } : {}), - ...(options ? { options } : {}), - ...(multiSelect ? { multiSelect: true } : {}), - ...(pendingId ? { pendingId } : {}) + ...(legacyOptions ? { options: legacyOptions } : {}), + ...(legacyMultiSelect ? { multiSelect: true } : {}), + ...(pendingId ? { pendingId } : {}), + ...(permissionSuggestions?.length ? { permissionSuggestions } : {}), + ...(questionPendingId ? { questionPendingId, ...(questions ? { questions } : {}) } : {}) }) } return { kind, pendingId } @@ -1949,6 +2019,8 @@ export function recordRawToolEvent(nodeId: string, payload: Record event.nodeId === nodeId && !event.resolved) + ) +} + /** * The nodes the mirror currently believes are `working`, with the identity a synthetic event needs. * Read-only peek for the shells — the reconnect resync asks the host about exactly these, because @@ -2470,3 +2554,10 @@ export function _snapshot(): Record { export function _inboxSnapshot(): MirrorInbox { return { events: inboxEvents.map((e) => ({ ...e })), nodes: Object.fromEntries(inboxNodes) } } + +/** Structured answers require the exact still-open card, never just the node's current badge. */ +export function hookTicketStillOpen(nodeId: string, pendingId: string, kind: 'approval' | 'question'): boolean { + return inboxEvents.some(e => e.nodeId === nodeId && e.kind === kind && !e.resolved && + (kind === 'approval' ? e.pendingId === pendingId && !!e.permissionSuggestions?.length + : e.questionPendingId === pendingId && !!e.questions?.length)) +} diff --git a/src/core/agents/hook-server.held.test.ts b/src/core/agents/hook-server.held.test.ts index 150b17f2cc..13986f376b 100644 --- a/src/core/agents/hook-server.held.test.ts +++ b/src/core/agents/hook-server.held.test.ts @@ -69,7 +69,7 @@ describe('hook server: held ticket gated on the posting script revision', () => expect(isStructuredTicket('held-node-1-1')).toBe(true) }) it('an older or unstamped script loses `held` but keeps its approve/deny pendingId', async () => { - for (const rev of [4, undefined]) { + for (const rev of [4, 5, undefined]) { events.length = 0 expect(await post('held-node-2-2', rev)).toBe(204) const e = events.at(-1)! diff --git a/src/core/agents/hook-server.ts b/src/core/agents/hook-server.ts index 64f7b79eff..47700b2aa3 100644 --- a/src/core/agents/hook-server.ts +++ b/src/core/agents/hook-server.ts @@ -5,7 +5,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import { randomUUID, timingSafeEqual } from 'crypto' import { readFileSync, mkdirSync, chmodSync, unlinkSync } from 'fs' import { assertHookEndpointAvailable, clearStaleHookSocket, HookSocketOwnedError } from './hook-socket-owner' -import { homedir } from 'os' import path from 'path' import { platform } from '../platform' import { writeFileAtomic } from '../fs-atomic' @@ -19,9 +18,8 @@ import { type GrokPermissionGate, type GrokPermissionGateDeps } from './grok-permission-gate' -import { classifyClaudeConfigDir, configDirFromTranscriptPath } from '../claude-accounts-core' -import { claudeAccountsSnapshot } from '../claude-config-dir' -import type { CodexIdentityEvent, ObservedClaudeAccount } from '../../shared/types' +import { observedClaudeAccount } from '../claude-config-dir' +import type { CodexIdentityEvent } from '../../shared/types' import type { NodeTokenVerdict } from './node-auth-token' import { nodeTokenDir } from './node-token-files' import { isForeignKidToken, isSafeNodeId, verifyNodeToken } from './node-auth-token' @@ -206,38 +204,6 @@ function parseClientRevision(raw: string | string[] | undefined): number | undef return Number.isSafeInteger(n) ? n : undefined } -/** - * The `account` LABEL for a claude hook payload: `transcript_path` → - * `/projects/…` → which account that dir is. Undefined for every other agent and for a - * payload with no usable `transcript_path` — "we did not observe an account" and "the system - * account" are different facts and must stay distinguishable (CONTRIBUTING: a failed read is never - * evidence of absence), so an absent field is the honest answer, not a synthesized system row. - * - * NEVER throws: this sits on the 204 path, and a classification failure — a settings store mid- - * write, a platform seam not yet initialized in an odd boot order — must cost the label, not the - * event. NO filesystem access happens here: the dir is classified as a string, so a forged - * POST naming `~/.ssh/projects/x.jsonl` gets a `known: false` label and nothing is opened. - */ -function observedClaudeAccount( - agentId: string, - payload: Record -): ObservedClaudeAccount | undefined { - if (agentId !== 'claude') return undefined - const tp = payload.transcript_path - if (typeof tp !== 'string' || !tp) return undefined - try { - const dir = configDirFromTranscriptPath(tp) - if (!dir) return undefined - return classifyClaudeConfigDir(dir, { - homeDir: homedir(), - userDataDir: platform().userDataDir, - accounts: claudeAccountsSnapshot() - }) - } catch { - return undefined - } -} - /** * What the hook server knows about the POST an event arrived on, beyond the event itself. * @@ -1072,6 +1038,7 @@ export class HookServer { // into the payload object so both the raw listener and the normalizers see it as if it // rode inside the hook JSON. See docs/hook-reply-approvals.md. if (form.nodeterm_pending_id) payload.nodeterm_pending_id = form.nodeterm_pending_id + if (form.nodeterm_hook_reply === '2') payload.nodeterm_hook_reply = 2 // Same treatment for the "answered" signal the wait branch fires on a valid allow/deny // answer (a separate form field it can't fold into the agent's JSON in POSIX sh). Merged // so the normalizer sees it and maps it to a synthetic working transition. See diff --git a/src/core/agents/hooks/antigravity-script.test.ts b/src/core/agents/hooks/antigravity-script.test.ts index 5728096048..ef6467d907 100644 --- a/src/core/agents/hooks/antigravity-script.test.ts +++ b/src/core/agents/hooks/antigravity-script.test.ts @@ -176,36 +176,35 @@ describe('the managed script for antigravity', () => { } }) - it('leaves the other six scripts byte-identical to the pre-antigravity build', () => { - // sha256 of each agent's script as built by origin/main at 06afa9d7 (the merge this branch - // was brought up to), computed from THAT commit's managed-script.ts — i.e. without any - // antigravity code — and compared equal to this branch's output before being pinned here. - // If the SHARED script legitimately changes, recompute these deliberately from the base - // commit's builder — a silent diff here is exactly the regression to catch. + it('pins the other six scripts after the reviewed shared reply-contract merge', () => { + // Reviewed shared changes: revision 6, Android marker-v2 POST fields and Claude's combined + // reply decoder. Non-Claude scripts otherwise match the upstream parent byte for byte; + // Antigravity's answer-first branch and event field must never enter any of these scripts. + // Keep literal hashes so a later shared change requires an explicit byte-diff review. const expected: Record = { claude: [ - '26e5dd697ee602a053c6a8ec054128dfb1fad0255b63d65efefed82106660ecc', - '6d7202781175407bc84d6831b1e1d5aba44574212ceb1c2e506aef5e9d9dac3d' + '9c9c5162f8c1c8494b199961bf481d8045d186766d9c43a142af3e5667590869', + '5a8b7e1e346d5d47aea4d34c34a02c7f7d092ed00476d16ac7f7667aaf83798f' ], codex: [ - '9b47a045193b8e434139215d8c6992bda1926e86eaa7078d666eb2fac254d660', - '736e0076437ff236673664d1d3c44807e9a0f54fe8cc49676f1a8a5978c787e1' + '8749b53e28d0737b8828f2ec17bbaef4c0862420b7169778984c5a0dfb3c71c7', + 'f6cf8f51e0663ce8bbd5690c3457a3375bf5b33b8b39fc34d2c7363d3e5b2a21' ], gemini: [ - '06d6c8ef62ae425c6aa253afc1536cb0913dc5ef4a173b1ce66c8a163849ec73', - 'bc1a805b3ceb015f61bdf2bc66fa5c9b8f6b0c236f4cfd761cba20db4dd0bafc' + 'a93dada65c07b7949ef3439da63e164c424e6ddf506fe08521c9011c2012f88b', + '2ac84c095747b9606b3521ad13ca36de5fb900fd8e914073db5f58ede2345374' ], opencode: [ - 'ee9c4a9a35e0456d1f5853a0737efe6d1064ad47942ab40cb79d3305fb232161', - 'b2935b048eb450759c9652eb1fb8b5047fc5816e28150f446eb2aeccc8941e51' + '2cc6e4b1ba62fa26b89748f00b3c13dec217d1576e312cecf08ce071940d41a2', + '7f02f36184bea465dbac79472899d1aa9cb6e268ed01686d5291acce1bdd6c72' ], grok: [ - '000363fb622b1fbe65579763557596ff4d0cab972cb61fb6c184e340ba1263ef', - '42c5f271ba8656a161dd1e06b05309b020fd4b92c95064c1ed70a6b9dab72672' + 'a71f634243895577a3843bd66800880685f4ce0c6866407ba29ebcf7ac68b375', + '1052768e5f11a578457e2f2c630f73598fd3e1156e15b4412fd0d4840f88c14e' ], copilot: [ - '3e21c72e394caa5c703cf1f06c017e82b3aedb6502f15c9ca7324e3e956c9678', - '766e961c8138cb1d2ade63be3fa420eeb1af7f53dbcfa658def6e09bd189b390' + '33c4698138a52156c3855b82625384ccad370fc853d6bdde1a69a707f395e88f', + '911dbeec29436d8a4ec5c506817d8d7517d9c7e3bba680c9f848e75e97a45be2' ] } const sha = (s: string): string => createHash('sha256').update(s).digest('hex') diff --git a/src/core/agents/hooks/managed-hook-answers.test.ts b/src/core/agents/hooks/managed-hook-answers.test.ts new file mode 100644 index 0000000000..deada3016e --- /dev/null +++ b/src/core/agents/hooks/managed-hook-answers.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, openSync, closeSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { spawnSync } from 'node:child_process' +import { buildManagedScript } from './managed-script' +import { buildHookReply } from '../../../shared/hook-answers' + +const homes: string[] = [] +afterEach(() => { for (const dir of homes.splice(0)) rmSync(dir, { recursive: true, force: true }) }) + +// Execute the shipped /bin/sh producer with only its HTTP transport replaced. No live hook server, +// agent account, listener or real home is involved; stdin and stdout use the real Claude schemas. +function run(request: unknown, answer: string | undefined, agent = 'claude') { + const home = mkdtempSync(join(tmpdir(), 'nt-hook-answer-')) + homes.push(home) + const bin = join(home, 'bin') + mkdirSync(bin) + writeFileSync(join(bin, 'curl'), `#!/bin/sh +pending= +answered= +for arg do + case "$arg" in + nodeterm_pending_id=*) pending="\${arg#*=}" ;; + nodeterm_answered=*) answered=1 ;; + nodeterm_hook_reply=*) printf '%s\\n' "$arg" >> "$TEST_POSTS" ;; + esac +done +if [ -n "$pending" ] && [ -z "$answered" ] && [ "$TEST_WRITE_ANSWER" = 1 ]; then + printf '%s' "$TEST_ANSWER" > "$HOME/.nodeterm/pending/$pending.answer" +fi +printf 204 +`, { mode: 0o700 }) + const script = join(home, 'hook.sh') + writeFileSync(script, buildManagedScript(agent, null)) + const input = join(home, 'stdin.json') + writeFileSync(input, JSON.stringify(request)) + const fd = openSync(input, 'r') + const child = spawnSync('/bin/sh', [script], { + stdio: [fd, 'pipe', 'pipe'], encoding: 'utf8', timeout: 5000, + env: { PATH: `${bin}:/usr/bin:/bin`, HOME: home, NODETERM_NODE_ID: 'node-1', NODETERM_HOOK_PORT: '1', + NODETERM_PERM_WAIT_SECS: '1', TEST_WRITE_ANSWER: answer === undefined ? '0' : '1', TEST_ANSWER: answer ?? '', TEST_POSTS: join(home, 'posts') } + }) + closeSync(fd) + if (child.error) throw new Error(`${child.error.message}\n${child.stderr.slice(-3500)}`) + expect(child.status).toBe(0) + return { stdout: child.stdout.trim(), posts: readFileSync(join(home, 'posts'), 'utf8'), pendingFiles: readdirSync(join(home, '.nodeterm/pending')) } +} + +const permission = { hook_event_name: 'PermissionRequest', tool_name: 'Bash', permission_suggestions: [ + { type: 'addRules', behavior: 'allow', destination: 'localSettings', rules: [{ toolName: 'Bash', ruleContent: 'npm test' }] } +] } +const question = { hook_event_name: 'PreToolUse', tool_name: 'AskUserQuestion', tool_use_id: 'tool-1', tool_input: { + questions: [{ question: 'Which?', header: 'Choice', multiSelect: true, options: [{ label: 'A', description: '' }, { label: 'B', description: '' }] }] +} } + +describe.skipIf(process.platform === 'win32')('real POSIX hook structured answers', () => { + it('preserves legacy allow and deny output', () => { + expect(JSON.parse(run(permission, 'allow').stdout).hookSpecificOutput.decision).toEqual({ behavior: 'allow' }) + expect(JSON.parse(run(permission, 'deny').stdout).hookSpecificOutput.decision).toEqual({ behavior: 'deny', message: 'Denied from nodeterm.' }) + }) + it('returns the exact concrete permission update in Claude decision JSON', () => { + const reply = buildHookReply(permission, { kind: 'allow-always', suggestionIndex: 0 })! + const result = run(permission, reply) + expect(JSON.parse(result.stdout).hookSpecificOutput).toEqual({ hookEventName: 'PermissionRequest', decision: { behavior: 'allow', updatedPermissions: permission.permission_suggestions } }) + expect(result.posts).toContain('nodeterm_hook_reply=2') + }) + it('returns the original question schema and multi-select answer through PreToolUse', () => { + const reply = buildHookReply(question, { kind: 'question', selections: [[0, 1]] })! + expect(JSON.parse(run(question, reply).stdout).hookSpecificOutput).toEqual({ hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput: { questions: question.tool_input.questions, answers: { 'Which?': 'A, B' } } }) + }) + it('refuses cross-kind or plain permission answers to a question rather than print a wrong hook decision', () => { + const permissionReply = buildHookReply(permission, { kind: 'allow-always', suggestionIndex: 0 })! + const questionReply = buildHookReply(question, { kind: 'question', selections: [[0]] })! + expect(run(question, permissionReply).stdout).toBe('') + expect(run(permission, questionReply).stdout).toBe('') + expect(run(question, 'allow').stdout).toBe('') + }) + it('releases an unanswered question after the bounded timeout and removes its held files', () => { + const result = run(question, undefined) + expect(result.stdout).toBe('') + expect(result.pendingFiles).toEqual([]) + }) + it('does not hold a child AskUserQuestion or an ordinary PreToolUse event', () => { + expect(run({ ...question, agent_id: 'child-1' }, undefined).pendingFiles).toEqual([]) + expect(run({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }, undefined).pendingFiles).toEqual([]) + }) + it('never arms or prints Claude decisions from a non-Claude script', () => { + expect(run(question, buildHookReply(question, { kind: 'question', selections: [[0]] })!, 'codex').stdout).toBe('') + }) +}) diff --git a/src/core/agents/hooks/managed-script.answer.test.ts b/src/core/agents/hooks/managed-script.answer.test.ts index 3546cf3664..1c6384421c 100644 --- a/src/core/agents/hooks/managed-script.answer.test.ts +++ b/src/core/agents/hooks/managed-script.answer.test.ts @@ -278,6 +278,15 @@ describe.skipIf(!shAvailable)('managed hook answer decoding, under /bin/sh', () expect(run(envelope('Bash', { command: 'ls' })).sleeps).toBe(2) }, 60_000) + it('keeps the v2 PreToolUse question on its armed hold instead of the PermissionRequest dialog wait', () => { + const payload = JSON.parse(envelope('AskUserQuestion', { questions: QUESTIONS })) + payload.hook_event_name = 'PreToolUse' + const result = run(JSON.stringify(payload)) + expect(result.sleeps).toBe(2) + expect(result.stdout).toBe('') + expect(result.pendingLeft).toEqual([]) + }) + it('where fractional sleep fails, the 1 s fallback counts double: the hold stays 540 s, not 1080 s', () => { const plan = run(envelope('ExitPlanMode', { plan: 'p' }), {}, { noFractionalSleep: true }) expect(plan.sleeps).toBe(PERM_WAIT_SECS_INTERACTIVE) // 540 one-second sleeps diff --git a/src/core/agents/hooks/managed-script.ts b/src/core/agents/hooks/managed-script.ts index 1684cf99c9..886f096ec6 100644 --- a/src/core/agents/hooks/managed-script.ts +++ b/src/core/agents/hooks/managed-script.ts @@ -84,6 +84,7 @@ * The prelude is prepended for EVERY agent, not just codex. It is inert without `CODEX_THREAD_ID`, * which no other agent's tool shell sets, and one builder beats a codex-only fork of it. */ +import { HOOK_REQUEST_MAX_BYTES, HOOK_REPLY_MARKER } from '../../../shared/hook-answers' import { codexThreadIdentityResolverSh } from '../../codex-thread-identity-sh' import { codexThreadIdentityRoot } from '../../codex-identity-proxy' import { HOOK_CURL_HEADERS_SH } from '../hook-curl-config-sh' @@ -120,7 +121,7 @@ import { * and any session the PHONE spawns on that host, which runs the host's installed script — stay * `legacy` until the project reconnects. */ -export const MANAGED_SCRIPT_REVISION = 5 +export const MANAGED_SCRIPT_REVISION = 6 /** The first revision that reads NODETERM_NODE_TOKEN_DIR and sends the node token (PR #195). */ export const MIN_TOKEN_AWARE_REVISION = 3 /* rev 4 (issue #384): the token read moved to the shared resolver in `node-token-sh.ts`, which @@ -130,7 +131,10 @@ export const MIN_TOKEN_AWARE_REVISION = 3 * rev 5: the answer file may carry a core-built JSON decision (plans/questions), a plain allow on * ExitPlanMode maps to `updatedInput:{}`, and those two tools hold 540 s. The server gates * structured answers on it (`MIN_STRUCTURED_ANSWER_REVISION`, permission-decision.ts) — an older - * script would silently ignore them while the write reported success. */ + * script would silently ignore them while the write reported success. + * rev 6: the merged script accepts both Android's marker-framed v2 replies and unmarked + * PermissionRequest decisions. Both parent branches used rev 5 for different formats, so rev 5 + * alone cannot prove support for the unmarked structured decision contract. */ /** * Which tool the held PermissionRequest is about, and how long to hold it (claude only; spliced @@ -376,7 +380,7 @@ export function buildManagedScript( ' fi', ' ;;', 'esac', - '# Deterministic-approval request: only for a PermissionRequest hook while the wait is armed.', + '# Hold a Claude PermissionRequest or parent AskUserQuestion only while the wait is armed.', '# `nt_pending` stays empty otherwise, so the POST tag and the poll loop below are both inert.', 'nt_pending=""', 'nt_pending_file=""', @@ -395,8 +399,16 @@ export function buildManagedScript( ...(agentId === 'claude' ? [ 'if [ -n "$NODETERM_PERM_WAIT_SECS" ] && [ "$NODETERM_PERM_WAIT_SECS" -gt 0 ] 2>/dev/null; then', + ' nt_pending_kind=', ' case "$payload" in', ' *\'"hook_event_name":"PermissionRequest"\'*|*\'"hook_event_name": "PermissionRequest"\'*)', + ' nt_pending_kind=permission ;;', + ' *\'"hook_event_name":"PreToolUse"\'*|*\'"hook_event_name": "PreToolUse"\'*)', + ' case "$payload" in *\'"tool_name":"AskUserQuestion"\'*|*\'"tool_name": "AskUserQuestion"\'*) nt_pending_kind=question ;; esac ;;', + ' esac', + ' # Child question events are ignored by the parent status reducer; never hold one without a card.', + ' if [ "$nt_pending_kind" = question ] && printf %s "$payload" | grep -Eq \'"agent_id"[[:space:]]*:[[:space:]]*"[^"[:space:]][^"]*"\'; then nt_pending_kind=; fi', + ' if [ -n "$nt_pending_kind" ]; then', ' nt_node=$(printf %s "$NODETERM_NODE_ID" | tr -c \'A-Za-z0-9_-\' \'_\')', ' nt_ms=$(date +%s%3N 2>/dev/null)', ' case "$nt_ms" in \'\'|*[!0-9]*) nt_ms=$(date +%s) ;; esac', @@ -406,8 +418,10 @@ export function buildManagedScript( ' nt_pending_file="$nt_dir/$nt_pending.json"', ' (umask 077; printf %s "$payload" > "$nt_pending_file") 2>/dev/null || :', ...HELD_TOOL_SH, - ' ;;', - ' esac', + // The v2 PreToolUse question has its own armed hold; the upstream long wait belongs + // to PermissionRequest's plan/question dialog, which is a separate hook contract. + ' if [ "$nt_pending_kind" = question ]; then nt_wait="$NODETERM_PERM_WAIT_SECS"; fi', + ' fi', 'fi' ] : [ @@ -435,6 +449,7 @@ export function buildManagedScript( ' --data-urlencode "version=${NODETERM_HOOK_VERSION}" \\', ' --data-urlencode "nodeterm_context_window=${nt_context_window}" \\', ' --data-urlencode "nodeterm_pending_id=${nt_pending}" \\', + ' --data-urlencode "nodeterm_hook_reply=2" \\', ...eventField, ' --data-urlencode "payload@${nt_payload_arg}" 2>/dev/null) || return 1', ' elif [ -n "$NODETERM_HOOK_PORT" ]; then', @@ -446,6 +461,7 @@ export function buildManagedScript( ' --data-urlencode "version=${NODETERM_HOOK_VERSION}" \\', ' --data-urlencode "nodeterm_context_window=${nt_context_window}" \\', ' --data-urlencode "nodeterm_pending_id=${nt_pending}" \\', + ' --data-urlencode "nodeterm_hook_reply=2" \\', ...eventField, ' --data-urlencode "payload@${nt_payload_arg}" 2>/dev/null) || return 1', ' else', @@ -522,8 +538,21 @@ export function buildManagedScript( // Take the file's BYTE size, then read at most one byte past the cap: a hostile multi-megabyte // file is never slurped into the shell, and the size check below rejects anything over it. ' nt_size=$(wc -c < "$nt_answer" 2>/dev/null)', - ` nt_decision=$(head -c ${PERMISSION_DECISION_MAX_BYTES + 1} "$nt_answer" 2>/dev/null)`, + ` nt_decision=$(head -c ${HOOK_REQUEST_MAX_BYTES + HOOK_REPLY_MARKER.length + 2} "$nt_answer" 2>/dev/null)`, ...ANSWER_DECODE_SH, + ' if [ "$(printf %s "$nt_decision" | sed -n \'1p\')" = nodeterm-hook-reply-v2 ]; then', + ' nt_out=$(printf %s "$nt_decision" | sed \'1d\')', + ' nt_verb=""', + ' case "$nt_pending_kind:$nt_out" in', + ' permission:\'{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow","updatedPermissions":\'*|question:\'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":\'*) nt_verb=allow ;;', + ' *) nt_out="" ;;', + ' esac', + ` if ! [ "$nt_size" -le ${HOOK_REQUEST_MAX_BYTES + HOOK_REPLY_MARKER.length + 1} ] 2>/dev/null; then nt_out=""; nt_verb=""; fi`, + " if [ -n \"$nt_out\" ] && [ \"$(printf '%s' \"$nt_out\" | tr -d '\\000-\\037')\" != \"$nt_out\" ]; then nt_out=\"\"; nt_verb=\"\"; fi", + ' elif [ "$nt_pending_kind" = question ]; then', + ' # PermissionRequest decisions cannot answer the separately held PreToolUse question.', + ' nt_out=""; nt_verb=hold', + ' fi', ' if [ "$nt_verb" = hold ]; then', ' # A plain allow on a question: consume it and KEEP HOLDING (see ANSWER_DECODE_SH).', ' rm -f "$nt_answer" 2>/dev/null || :', diff --git a/src/core/agents/pane-ownership.ts b/src/core/agents/pane-ownership.ts index 1a5aebd158..1dd44d8d31 100644 --- a/src/core/agents/pane-ownership.ts +++ b/src/core/agents/pane-ownership.ts @@ -14,10 +14,16 @@ * session existed to reattach to). An attach/co-attach to a session someone else spawned never * records, so a second project that merely OPENS a node id another project is running cannot * claim it. `fresh` is the manager's own signal, not anything off the wire or the file. + * The one other spawn path is a session the PHONE starts over the relay (`PtyManager. + * attachDetached`, audit A72): recorded only when the relay host's probe of the same manager + * (`sessionExists`, fail-safe toward "exists") found no session AND this process holds no live + * generation of the node — the same "genuinely brought into being" fact, asked the only way a + * synchronous attach can ask it. * - The owner value is the machine-local project id the renderer passed at the create call * (`PtyCreateOptions.ownerProjectId`) — the entry id (`IndexEntryV3.id`), never the file's * git-copied `id`. A cloned repo gets a fresh entry id, so it cannot inherit another copy's - * ownership either. + * ownership either. For a phone-started session the relay host resolves that same entry id from + * its own index (`createHostNewSessions`) — the phone names a project, it never supplies an owner. * * ── COLD STATE / RESTART (stated honestly) ────────────────────────────────────────────────────── * This ledger is IN-MEMORY and starts empty every run. After an app restart the tmux SERVER @@ -58,8 +64,9 @@ export function shouldRecordOwnership( } /** - * Record the owner of a node whose pane was just GENUINELY spawned. Call site: `spawnNew`, guarded - * by `fresh === true` and a present `persistKey` + `ownerProjectId`. A fresh spawn for an id that + * Record the owner of a node whose pane was just GENUINELY spawned. Call sites: `spawnNew`, guarded + * by `fresh === true` and a present `persistKey` + `ownerProjectId`, and `attachDetached` for a + * session the relay host creates (see the header). A fresh spawn for an id that * somehow already has an entry OVERWRITES it — the live pane is the one that just came into being. * A missing owner is a no-op (old callers / tests that pass no `ownerProjectId` simply leave the * pane unproven, which fails closed downstream — the correct direction). diff --git a/src/core/agents/pending-approvals.test.ts b/src/core/agents/pending-approvals.test.ts index a225bb0acf..962057a66a 100644 --- a/src/core/agents/pending-approvals.test.ts +++ b/src/core/agents/pending-approvals.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import fs from 'fs' import os from 'os' import path from 'path' @@ -6,6 +6,9 @@ import { isValidPendingId, pendingDir, writePendingAnswerLocal, + answerPendingLocal, + answerPendingHookLocal, + pendingBelongsToNode, sweepPendingDir, syntheticAnsweredEvent, PENDING_MAX_AGE_MS, @@ -26,6 +29,7 @@ beforeEach(() => { home = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-pending-')) }) afterEach(() => { + vi.restoreAllMocks() fs.rmSync(home, { recursive: true, force: true }) }) @@ -45,8 +49,15 @@ describe('isValidPendingId', () => { }) }) +/** A hook currently holding `id` (the request file the managed script writes and deletes). */ +function hold(id: string): void { + fs.mkdirSync(pendingDir(home), { recursive: true }) + fs.writeFileSync(path.join(pendingDir(home), `${id}.json`), '{}') +} + describe('writePendingAnswerLocal', () => { it('writes the one-line answer file for a valid id + decision', async () => { + hold('node-1-2') const ok = await writePendingAnswerLocal('node-1-2', 'allow', home) expect(ok).toBe(true) const file = path.join(pendingDir(home), 'node-1-2.answer') @@ -54,14 +65,16 @@ describe('writePendingAnswerLocal', () => { }) it('writes deny too', async () => { + hold('n') expect(await writePendingAnswerLocal('n', 'deny', home)).toBe(true) expect(fs.readFileSync(path.join(pendingDir(home), 'n.answer'), 'utf8')).toBe('deny') }) it('leaves no .tmp behind (atomic rename)', async () => { + hold('n') await writePendingAnswerLocal('n', 'allow', home) - const entries = fs.readdirSync(pendingDir(home)) - expect(entries).toEqual(['n.answer']) + const entries = fs.readdirSync(pendingDir(home)).sort() + expect(entries).toEqual(['n.answer', 'n.json']) }) it('refuses an invalid pendingId and writes nothing', async () => { @@ -70,13 +83,23 @@ describe('writePendingAnswerLocal', () => { }) it('refuses content the hook script would not print (the same bound)', async () => { + hold('n') expect(await writePendingAnswerLocal('n', 'always', home)).toBe(false) expect(await writePendingAnswerLocal('n', '{"hookSpecificOutput":{"hookEventName":"PreToolUse"}}', home)).toBe(false) expect(await writePendingAnswerLocal('n', `${PERMISSION_DECISION_PREFIX}"allow"}}}\nX`, home)).toBe(false) expect(fs.existsSync(path.join(pendingDir(home), 'n.answer'))).toBe(false) }) + it('refuses a structured decision after the held request is gone', async () => { + hold('n') + fs.unlinkSync(path.join(pendingDir(home), 'n.json')) + const json = `${PERMISSION_DECISION_PREFIX}"allow","updatedInput":{}}}}` + expect(await writePendingAnswerLocal('n', json, home)).toBe(false) + expect(fs.readdirSync(pendingDir(home))).toEqual([]) + }) + it('writes a core-built JSON decision verbatim, 0600', async () => { + hold('n') const json = `${PERMISSION_DECISION_PREFIX}"allow","updatedInput":{}}}}` expect(await writePendingAnswerLocal('n', json, home)).toBe(true) const f = path.join(pendingDir(home), 'n.answer') @@ -130,6 +153,108 @@ describe('readPendingRequestLocal + localHeldPermissionIo', () => { `${PERMISSION_DECISION_PREFIX}"allow","updatedInput":{}}}}` ) }) + + // A06: the hook deletes .json when its 45 s hold ends. Writing the answer anyway used to + // report success (and clear NEEDS YOU everywhere) for an answer nothing would ever read. + it('reports false and writes nothing once the hold has ended', async () => { + fs.mkdirSync(pendingDir(home), { recursive: true }) + expect(await writePendingAnswerLocal('late-1', 'allow', home)).toBe(false) + expect(fs.existsSync(path.join(pendingDir(home), 'late-1.answer'))).toBe(false) + }) +}) + +describe('answerPendingLocal', () => { + it('says sent for a live hold', async () => { + hold('live-1') + expect(await answerPendingLocal('live-1', 'deny', home)).toBe('sent') + }) + + it('says gone — not failed — when the hold ended (A35)', async () => { + expect(await answerPendingLocal('late-1', 'allow', home)).toBe('gone') + fs.mkdirSync(pendingDir(home), { recursive: true }) + expect(await answerPendingLocal('late-1', 'allow', home)).toBe('gone') + }) + + it('says failed when the answer cannot be written', async () => { + hold('ro-1') + // A directory where the answer file should go makes the rename fail. + fs.mkdirSync(path.join(pendingDir(home), 'ro-1.answer')) + expect(await answerPendingLocal('ro-1', 'allow', home)).toBe('failed') + }) + + it('says failed for an invalid id', async () => { + expect(await answerPendingLocal('../x', 'allow', home)).toBe('failed') + }) +}) + +describe('structured hook replies', () => { + const request = { hook_event_name: 'PermissionRequest', tool_name: 'Bash', permission_suggestions: [ + { type: 'addRules', behavior: 'allow', destination: 'localSettings', rules: [{ toolName: 'Bash', ruleContent: 'npm test' }] } + ] } + function holdRequest(id = 'node-99-7', value: unknown = request): void { + hold(id) + fs.writeFileSync(path.join(pendingDir(home), `${id}.json`), JSON.stringify(value)) + } + it('binds the ticket to its exact node and script suffix', () => { + expect(pendingBelongsToNode('node', 'node-99-7')).toBe(true) + for (const id of ['other-99-7', 'node-suffix', 'node-extra-99-7']) expect(pendingBelongsToNode('node', id)).toBe(false) + }) + it('writes only a live owned concrete suggested rule, privately and atomically', async () => { + holdRequest() + expect(await answerPendingHookLocal('node', 'node-99-7', { kind: 'allow-always', suggestionIndex: 0 }, home)).toBe('sent') + const file = path.join(pendingDir(home), 'node-99-7.answer') + const [marker, output] = fs.readFileSync(file, 'utf8').split('\n') + expect(marker).toBe('nodeterm-hook-reply-v2') + expect(JSON.parse(output).hookSpecificOutput.decision.updatedPermissions).toEqual(request.permission_suggestions) + if (process.platform !== 'win32') expect(fs.statSync(file).mode & 0o777).toBe(0o600) + expect(fs.readdirSync(pendingDir(home)).sort()).toEqual(['node-99-7.answer', 'node-99-7.json']) + }) + it('refuses foreign tickets, ended holds, malformed or oversized requests without an answer', async () => { + holdRequest() + expect(await answerPendingHookLocal('other', 'node-99-7', { kind: 'allow-always', suggestionIndex: 0 }, home)).toBe('failed') + expect(await answerPendingHookLocal('node', 'node-99-8', { kind: 'allow-always', suggestionIndex: 0 }, home)).toBe('gone') + for (const bytes of ['not json', 'x'.repeat(128 * 1024 + 1)]) { + fs.writeFileSync(path.join(pendingDir(home), 'node-99-7.json'), bytes) + expect(await answerPendingHookLocal('node', 'node-99-7', { kind: 'allow-always', suggestionIndex: 0 }, home)).toBe('failed') + } + expect(fs.existsSync(path.join(pendingDir(home), 'node-99-7.answer'))).toBe(false) + }) + it('refuses a held file replaced during the bounded read', async () => { + holdRequest() + const read = fs.promises.readFile.bind(fs.promises) + vi.spyOn(fs.promises, 'readFile').mockImplementationOnce(async (...args: any[]) => { + const bytes = await (read as any)(...args) + const next = path.join(pendingDir(home), 'replacement.json') + fs.writeFileSync(next, JSON.stringify(request)) + fs.renameSync(next, path.join(pendingDir(home), 'node-99-7.json')) + return bytes + }) + expect(await answerPendingHookLocal('node', 'node-99-7', { kind: 'allow-always', suggestionIndex: 0 }, home)).toBe('failed') + expect(fs.existsSync(path.join(pendingDir(home), 'node-99-7.answer'))).toBe(false) + }) + it('does not publish if the hook times out while the complete reply is being staged', async () => { + holdRequest() + const write = fs.promises.writeFile.bind(fs.promises) + vi.spyOn(fs.promises, 'writeFile').mockImplementationOnce(async (...args: any[]) => { + await (write as any)(...args) + fs.unlinkSync(path.join(pendingDir(home), 'node-99-7.json')) + }) + expect(await answerPendingHookLocal('node', 'node-99-7', { kind: 'allow-always', suggestionIndex: 0 }, home)).toBe('gone') + expect(fs.readdirSync(pendingDir(home))).toEqual([]) + }) + it.skipIf(process.platform === 'win32')('does not follow a symlink masquerading as a held request', async () => { + holdRequest('other-1-1') + fs.symlinkSync(path.join(pendingDir(home), 'other-1-1.json'), path.join(pendingDir(home), 'node-99-7.json')) + expect(await answerPendingHookLocal('node', 'node-99-7', { kind: 'allow-always', suggestionIndex: 0 }, home)).toBe('failed') + expect(fs.existsSync(path.join(pendingDir(home), 'node-99-7.answer'))).toBe(false) + }) + it('writes structured question answers without changing the requested tool input', async () => { + const questions = [{ question: 'Which?', header: 'Options', multiSelect: true, options: [{ label: 'A', description: '' }, { label: 'B', description: '' }] }] + holdRequest('node-99-7', { hook_event_name: 'PreToolUse', tool_name: 'AskUserQuestion', tool_input: { questions } }) + expect(await answerPendingHookLocal('node', 'node-99-7', { kind: 'question', selections: [[0, 1]] }, home)).toBe('sent') + const result = JSON.parse(fs.readFileSync(path.join(pendingDir(home), 'node-99-7.answer'), 'utf8').split('\n')[1]) + expect(result.hookSpecificOutput).toEqual({ hookEventName: 'PreToolUse', permissionDecision: 'allow', updatedInput: { questions, answers: { 'Which?': 'A, B' } } }) + }) }) describe('syntheticAnsweredEvent (optimistic-flip builder)', () => { diff --git a/src/core/agents/pending-approvals.ts b/src/core/agents/pending-approvals.ts index 5d39d564e2..a335dd971a 100644 --- a/src/core/agents/pending-approvals.ts +++ b/src/core/agents/pending-approvals.ts @@ -9,10 +9,12 @@ // pendingId or an fs error resolves false / logs, never throws. import fs from 'fs' +import { randomUUID } from 'crypto' import os from 'os' import path from 'path' -import { writeFileAtomic } from '../fs-atomic' +import { renameAtomic, writeFileAtomic } from '../fs-atomic' import { normalizeClaude, type NormalizedAgentEvent } from '../../shared/agents/normalize' +import { buildHookReply, HOOK_REQUEST_MAX_BYTES, type HookAnswer } from '../../shared/hook-answers' import { isBoundedAnswerContent, PENDING_REQUEST_MAX_BYTES, type HeldPermissionIo } from './permission-decision' /** pendingId shape the script generates (`--`) and the ONLY thing we interpolate @@ -35,10 +37,87 @@ export function pendingDir(homeDir: string = os.homedir()): string { } /** - * Write the answer file for a held permission hook, atomically (tmp + rename, mode 0600). - * Resolves true on success, false on an invalid pendingId, content the hook script would not - * print (`isBoundedAnswerContent` — the legacy words, or a core-built JSON decision), or any fs - * error (fail-open — the hook simply times out to the interactive prompt). + * What writing an answer did. `gone` is the hook's hold having ENDED — it deletes + * `.json` when it times out (managed-script.ts) or when another surface answered — so no + * answer can reach it any more and the interactive prompt is (or was) on screen instead. `failed` is + * a write that could not happen. The two are different facts for a caller: one says "go to the + * session", the other "try again" (audit A06/A35). + */ +export type PendingAnswerResult = 'sent' | 'gone' | 'failed' + +/** The script appends its timestamp and PID to THIS node, not an arbitrary valid filename. */ +export function pendingBelongsToNode(nodeId: string, pendingId: string): boolean { + return /^[A-Za-z0-9_-]{1,200}$/.test(nodeId) && isValidPendingId(pendingId) && + pendingId.startsWith(`${nodeId}-`) && /^\d+-\d+$/.test(pendingId.slice(nodeId.length + 1)) +} + +/** Structured v2 answers are derived from the live request; clients never send hook output. */ +export async function answerPendingHookLocal( + nodeId: string, pendingId: string, answer: HookAnswer, homeDir: string = os.homedir() +): Promise { + if (!pendingBelongsToNode(nodeId, pendingId)) return 'failed' + const dir = pendingDir(homeDir) + const request = path.join(dir, `${pendingId}.json`) + let temporary: string | undefined + try { + const before = await fs.promises.lstat(request) + if (!before.isFile() || before.isSymbolicLink() || before.size > HOOK_REQUEST_MAX_BYTES) return 'failed' + const bytes = await fs.promises.readFile(request) + if (bytes.length > HOOK_REQUEST_MAX_BYTES) return 'failed' + const reply = buildHookReply(JSON.parse(bytes.toString('utf8')), answer) + if (!reply) return 'failed' + temporary = path.join(dir, `.reply-${randomUUID()}.tmp`) + await fs.promises.writeFile(temporary, reply, { mode: 0o600, flag: 'wx' }) + // Check AFTER the complete reply is written: a timeout during that write must not publish. + const after = await fs.promises.lstat(request) + if (!after.isFile() || after.isSymbolicLink() || before.ino !== after.ino || before.dev !== after.dev || + before.mtimeMs !== after.mtimeMs || before.size !== after.size) return 'failed' + await renameAtomic(temporary, path.join(dir, `${pendingId}.answer`)) + return 'sent' + } catch (e) { + return (e as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'gone' : 'failed' + } finally { + if (temporary) await fs.promises.unlink(temporary).catch(() => {}) + } +} + +/** + * Answer a held permission hook: check its request file still exists, then write the one-line answer + * file atomically (tmp + rename, mode 0600). The `decision` is written verbatim as the hook script + * compares it against the literals `allow` / `deny`. Never throws. + * + * The existence check is what stops a late answer (the phone's usual case: the hold is 45 s) from + * being reported — and optimistically broadcast — as delivered. A hook that times out between the + * check and the write leaves an orphan `.answer`, which the sweep removes; closing that window fully + * would need the hook to announce its timeout. + */ +export async function answerPendingLocal( + pendingId: string, + decision: 'allow' | 'deny', + homeDir: string = os.homedir() +): Promise { + if (!isValidPendingId(pendingId)) return 'failed' + if (decision !== 'allow' && decision !== 'deny') return 'failed' + const dir = pendingDir(homeDir) + try { + await fs.promises.access(path.join(dir, `${pendingId}.json`)) + } catch (e) { + // Only a definite ENOENT is evidence the hold ended; anything else is a failed read. + return (e as NodeJS.ErrnoException)?.code === 'ENOENT' ? 'gone' : 'failed' + } + const file = path.join(dir, `${pendingId}.answer`) + try { + // writeFileAtomic: unique tmp + retrying rename (core/fs-atomic.ts); removes its temp on failure. + await writeFileAtomic(file, decision, { mode: 0o600 }) + return 'sent' + } catch { + return 'failed' + } +} + +/** + * Write legacy words or an upstream structured decision for a hold that still exists. + * Content must satisfy the managed script's bound; an expired hold is never reported as sent. */ export async function writePendingAnswerLocal( pendingId: string, @@ -50,7 +129,7 @@ export async function writePendingAnswerLocal( const dir = pendingDir(homeDir) const file = path.join(dir, `${pendingId}.answer`) try { - await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }) + await fs.promises.access(path.join(dir, `${pendingId}.json`)) // writeFileAtomic: unique tmp + retrying rename (core/fs-atomic.ts); removes its temp on failure. await writeFileAtomic(file, content, { mode: 0o600 }) return true diff --git a/src/core/agents/permission-decision.test.ts b/src/core/agents/permission-decision.test.ts index 3101eae7f5..89dc840e91 100644 --- a/src/core/agents/permission-decision.test.ts +++ b/src/core/agents/permission-decision.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { _resetStructuredTicketsForTest, answerHeldPermission, @@ -357,7 +357,7 @@ describe('answerHeldPermission — the one orchestration both shells call', () = const T = 'node-1-1720-42' beforeEach(() => { _resetStructuredTicketsForTest() - // A capable (rev >= 5) script posted this ticket — the hook server's labeler records it. + // A capable merged script posted this ticket — the hook server's labeler records it. labelHeldForRevision( { nodeId: 'n', agentId: 'claude', kind: 'state', state: 'blocked', held: { pendingId: T, toolName: 'ExitPlanMode' } }, MIN_STRUCTURED_ANSWER_REVISION @@ -419,6 +419,26 @@ describe('answerHeldPermission — the one orchestration both shells call', () = describe('a ticket held by an OLDER script (an SSH host keeps its script until reconnect)', () => { const OLD = 'node-2-1720-43' + it.each(['plan', 'question'] as const)('refuses a revision-5 %s answer before host I/O or optimistic success', async (kind) => { + const toolName = kind === 'plan' ? 'ExitPlanMode' : 'AskUserQuestion' + const event = labelHeldForRevision({ + nodeId: 'node-2', agentId: 'claude', kind: 'state', state: 'blocked', + pendingId: OLD, held: { pendingId: OLD, toolName } + }, 5) + const x = io(kind === 'plan' + ? envelope(toolName, { plan: 'p' }) + : envelope(toolName, { questions: REAL_QUESTIONS })) + const read = vi.spyOn(x, 'readPending') + const answer = kind === 'plan' + ? { kind: 'plan', mode: 'restore' } + : { kind: 'question', answers: { ...ANSWER_1, ...ANSWER_2 } } + expect(await answerHeldPermission(OLD, { answer }, x)).toEqual({ ok: false }) + expect(read).not.toHaveBeenCalled() + expect(x.writes).toEqual([]) + expect(event.held).toBeUndefined() + expect(event.pendingId).toBe(OLD) + expect(isStructuredTicket(OLD)).toBe(false) + }) it('refuses a structured answer before touching the host — no false "answered"', async () => { const x = io(envelope('ExitPlanMode', { plan: 'p' })) expect(await answerHeldPermission(OLD, { answer: { kind: 'plan', mode: 'restore' } }, x)).toEqual({ ok: false }) @@ -461,7 +481,7 @@ describe('labelHeldForRevision (the hook server stamps each held ticket)', () => expect(isStructuredTicket('a-1-1')).toBe(true) }) it('drops `held` (and records nothing) for an older or unstamped script; pendingId is untouched', () => { - for (const rev of [undefined, 3, 4]) { + for (const rev of [undefined, 3, 4, 5]) { const out = labelHeldForRevision(blocked('b-1-1'), rev) expect('held' in out, String(rev)).toBe(false) expect(out.pendingId).toBe('b-1-1') @@ -472,8 +492,19 @@ describe('labelHeldForRevision (the hook server stamps each held ticket)', () => const ev = { nodeId: 'n', agentId: 'claude', kind: 'state' as const, state: 'working' as const } expect(labelHeldForRevision(ev, 5)).toBe(ev) }) + it('retains separately advertised Android v2 capabilities on revision 5', () => { + const approval = { ...blocked('v2-1-1'), permissionSuggestions: [{ index: 0, label: 'Bash(ls): session' }] } + const out = labelHeldForRevision(approval, 5) + expect(out.held).toBeUndefined() + expect(out.permissionSuggestions).toEqual(approval.permissionSuggestions) + const question = { + nodeId: 'n', agentId: 'claude', kind: 'state' as const, state: 'waiting' as const, + questionId: 'tool-1', questionPendingId: 'v2-2-2' + } + expect(labelHeldForRevision(question, 5)).toBe(question) + }) it('stays bounded', () => { - for (let i = 0; i < 1100; i++) labelHeldForRevision(blocked(`t-${i}`), 5) + for (let i = 0; i < 1100; i++) labelHeldForRevision(blocked(`t-${i}`), MIN_STRUCTURED_ANSWER_REVISION) expect(isStructuredTicket('t-0')).toBe(false) expect(isStructuredTicket('t-1099')).toBe(true) }) diff --git a/src/core/agents/permission-decision.ts b/src/core/agents/permission-decision.ts index 4c4edc03ad..39d8a24cbe 100644 --- a/src/core/agents/permission-decision.ts +++ b/src/core/agents/permission-decision.ts @@ -305,17 +305,20 @@ export function isBoundedAnswerContent(content: string): boolean { } /** - * The first managed-script revision that understands a JSON answer (and maps a plain `allow` on a + * The first unambiguous merged revision that understands an unmarked JSON answer (and maps a plain `allow` on a * plan). `MANAGED_SCRIPT_REVISION` must be >= this — pinned in managed-script.answer.test.ts. It * lives HERE rather than in managed-script.ts because that module imports this one. * * Why a revision gate at all: an SSH host gets a new script only at CONNECT, so a long-connected * project can hold a request with an older script. That script reads a JSON answer as neither * `allow` nor `deny`, deletes it and prints nothing — the TUI dialog stays — while the write itself - * succeeded. Without this gate core would report success and the shells would flip NEEDS YOU to - * "working" over an agent still waiting in its TUI. + * succeeded. Both parent branches advertised revision 5: one accepts marker-framed Android v2 + * replies, the other accepts unmarked PermissionRequest decisions. Require revision 6 for this + * latter contract. Android's separately advertised v2 capabilities do not depend on this gate. + * Without it core would report success and the shells would flip NEEDS YOU to "working" over + * an agent still waiting in its TUI. */ -export const MIN_STRUCTURED_ANSWER_REVISION = 5 +export const MIN_STRUCTURED_ANSWER_REVISION = 6 /** Tickets whose PermissionRequest was posted by a script >= MIN_STRUCTURED_ANSWER_REVISION. * Process-local and bounded: the only producer is this process's hook server, so a ticket we never diff --git a/src/core/claude-config-dir.ts b/src/core/claude-config-dir.ts index fefdc3870b..336995778d 100644 --- a/src/core/claude-config-dir.ts +++ b/src/core/claude-config-dir.ts @@ -10,9 +10,15 @@ // rather than threading the list through pty-manager, the usage service, the transcript readers // and both jails: a second copy of "where does this account's config live?" is exactly the drift // CLAUDE.md warns about, and the answer moved for linked accounts. +import { homedir } from 'os' import { platform } from './platform' -import { accountConfigDir, normalizeLinkedConfigDir } from './claude-accounts-core' -import type { ClaudeAccount } from '../shared/types' +import { + accountConfigDir, + classifyClaudeConfigDir, + configDirFromTranscriptPath, + normalizeLinkedConfigDir +} from './claude-accounts-core' +import type { ClaudeAccount, ObservedClaudeAccount } from '../shared/types' let accountsSource: (() => readonly ClaudeAccount[]) | undefined @@ -82,3 +88,39 @@ export function claudeConfigDirFor(accountId: string): string { const managed = accountConfigDir(platform().userDataDir, accountId) return linkedClaudeConfigDirFor(accountId) ?? managed } + +/** + * The `account` LABEL for a claude hook payload: `transcript_path` → + * `/projects/…` → which account that dir is. Undefined for every other agent and for a + * payload with no usable `transcript_path` — "we did not observe an account" and "the system + * account" are different facts and must stay distinguishable (CONTRIBUTING: a failed read is never + * evidence of absence), so an absent field is the honest answer, not a synthesized system row. + * + * The hook server attaches it to every normalized event (hook-server.ts), once, for both shells. It + * lives here rather than inside the server so the Android interop fixture labels the events it + * feeds the agent-status mirror with this same function (audit A64). + * + * NEVER throws: this sits on the 204 path, and a classification failure — a settings store mid- + * write, a platform seam not yet initialized in an odd boot order — must cost the label, not the + * event. NO filesystem access happens here: the dir is classified as a string, so a forged + * POST naming `~/.ssh/projects/x.jsonl` gets a `known: false` label and nothing is opened. + */ +export function observedClaudeAccount( + agentId: string, + payload: Record +): ObservedClaudeAccount | undefined { + if (agentId !== 'claude') return undefined + const tp = payload.transcript_path + if (typeof tp !== 'string' || !tp) return undefined + try { + const dir = configDirFromTranscriptPath(tp) + if (!dir) return undefined + return classifyClaudeConfigDir(dir, { + homeDir: homedir(), + userDataDir: platform().userDataDir, + accounts: claudeAccountsSnapshot() + }) + } catch { + return undefined + } +} diff --git a/src/core/codex-cli.ts b/src/core/codex-cli.ts index 53a99821d9..b7ba15d6ba 100644 --- a/src/core/codex-cli.ts +++ b/src/core/codex-cli.ts @@ -35,79 +35,8 @@ import { CODEX_NO_DAEMON_HELP_RE } from '../shared/agents/codex-daemon' const execFileP = promisify(execFile) const PROBE_TIMEOUT_MS = 5000 -/** - * Only the option we are reading, never the one next to it. - * - * `--help` lists `-s, --sandbox ` with its own `[possible values: read-only, - * workspace-write, danger-full-access]` two lines above `--ask-for-approval`, so a parser that - * scanned the page for "possible values" would confidently return the SANDBOX vocabulary and then - * emit `--ask-for-approval read-only`. The slice below is anchored on the option's own header line - * and ends at the next one. - */ -const ASK_FOR_APPROVAL = /--ask-for-approval\b/ -/** An option header: indented at most 6 and starting with a dash. Value/description lines inside an - * option are indented 10, so this cannot cut a block short. */ -const OPTION_HEADER = /^ {0,6}-{1,2}[A-Za-z]/ - -/** - * Pure: `codex --help` output → the `--ask-for-approval` values it advertises, or `null` when the - * page did not say (absent output, an option we could not find, a shape we do not recognise). - * - * `null` is a first-class answer and NOT an empty list: "the CLI accepts nothing" and "we could not - * read the page" lead to different decisions one layer up, where `null` resolves to the baseline - * vocabulary and an empty list would forbid every value including the two that have always worked. - * - * BOTH of clap's renderings are handled, because both are real. Long `--help` prints a block: - * - * -a, --ask-for-approval - * Configure when the model requires human approval before executing a command - * - * Possible values: - * - on-request: The model decides when to ask the user for approval - * - never: Never ask for user approval Execution failures are immediately - * returned to the model - * - * short `-h` prints it inline and wraps it mid-phrase: - * - * -a, --ask-for-approval - * Configure when the model requires human approval ... [possible - * values: on-request, never] - * - * — which is why the inline branch joins the slice before matching rather than working line by - * line. The probe below asks for the long form; the short form is handled so a caller that has - * `-h` output lying around gets the same answer instead of a silent `null`. - * - * Every token is validated against `[a-z][a-z0-9-]*`. These strings are appended to a launch - * command that is typed into a tmux pane, so the help page is untrusted input at an interpolation - * site like any other — a token we would not recognise is dropped rather than quoted and hoped - * for, and if nothing survives the answer is `null`. - */ -export function codexApprovalValuesFrom(helpOutput: string | null | undefined): string[] | null { - if (!helpOutput) return null - const lines = helpOutput.split(/\r?\n/) - const start = lines.findIndex((l) => ASK_FOR_APPROVAL.test(l)) - if (start < 0) return null - let end = start + 1 - while (end < lines.length && !OPTION_HEADER.test(lines[end])) end++ - const slice = lines.slice(start, end) - - const values: string[] = [] - const inline = slice.join(' ').match(/\[possible\s+values:\s*([^\]]*)\]/i) - if (inline) { - values.push(...inline[1].split(',')) - } else { - // The block form. Anchored on the `Possible values:` marker so a bullet inside the option's - // prose description can never be mistaken for a value. - const marker = slice.findIndex((l) => /^\s*possible\s+values:\s*$/i.test(l)) - if (marker < 0) return null - for (const line of slice.slice(marker + 1)) { - const m = line.match(/^\s*-\s+([^\s:]+)\s*:/) - if (m) values.push(m[1]) - } - } - const clean = values.map((v) => v.trim()).filter((v) => /^[a-z][a-z0-9-]*$/.test(v)) - return clean.length ? Array.from(new Set(clean)) : null -} +import { codexApprovalValuesFrom } from '../shared/agents/codex-approval-values' +export { codexApprovalValuesFrom } from '../shared/agents/codex-approval-values' /** * Pure: `codex --help` output → does this CLI accept `--no-daemon`? `null` when there is no page to diff --git a/src/core/composed-pty.test.ts b/src/core/composed-pty.test.ts new file mode 100644 index 0000000000..4b356ccc23 --- /dev/null +++ b/src/core/composed-pty.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ComposedPty, COMPOSED_TICKET_TTL_MS, waitForComposedEnter } from './composed-pty' + +const owner = {}, foreign = {} +const paste = { kind: 'paste', text: 'one\r\ntwo\nΩ\x1b\x9b', enter: true } as const +const panes: ComposedPty[] = [] +function fixture(bracketed = true) { + const write = vi.fn(), mode = vi.fn(async () => bracketed) + const pane = new ComposedPty({ bracketed: mode, write }); panes.push(pane) + const prepare = (value: unknown = paste, who = owner) => { + const result = pane.prepare(who, value, () => true) + expect(result.status).toBe('prepared') + if (result.status !== 'prepared') throw new Error('fixture could not prepare') + return result.ticket + } + return { pane, mode, write, prepare } +} +afterEach(() => { panes.splice(0).forEach((p) => p.dispose()); vi.useRealTimers() }) + +describe('explicit composed input on a captured direct PTY', () => { + it.each([true, false])('normalizes a full paste and separates Enter by at least 150 ms (mode=%s)', async (bracketed) => { + const { pane, write, prepare } = fixture(bracketed) + const ticket = prepare() + expect(await pane.write(owner, ticket, 'paste', () => true)).toEqual({ status: 'awaiting-enter' }) + expect(write.mock.calls).toEqual([[bracketed ? '\x1b[200~one\rtwo\rΩ\x1b[201~' : 'one\rtwo\rΩ']]) + expect(pane.prepare(foreign, paste, () => true).status).toBe('refused') + await waitForComposedEnter() + expect(await pane.write(owner, ticket, 'enter', () => true)).toEqual({ status: 'delivered' }) + expect(write.mock.calls.at(-1)).toEqual(['\r']) + expect((await pane.write(owner, ticket, 'paste', () => true)).status).toBe('refused') + expect((await pane.write(owner, ticket, 'enter', () => true)).status).toBe('refused') + expect(write).toHaveBeenCalledTimes(2) + }) + it('never frames a raw Ctrl, queries paste mode or appends Enter', async () => { + const { pane, mode, write, prepare } = fixture() + const ticket = prepare({ kind: 'control', text: '\x03', enter: false }) + expect(await pane.write(owner, ticket, 'paste', () => true)).toEqual({ status: 'delivered' }) + expect(write.mock.calls).toEqual([['\x03']]); expect(mode).not.toHaveBeenCalled() + }) + it('refuses foreign tickets and malformed inputs without releasing the rightful action', async () => { + const { pane, write, prepare } = fixture() + for (const input of [{ ...paste, text: 'nul\0' }, { kind: 'control', text: '\x03', enter: true }, + { ...paste, text: 'é'.repeat(131_073) }]) expect(pane.prepare(owner, input, () => true).status).toBe('refused') + const ticket = prepare() + expect((await pane.write(owner, 'foreign-ticket', 'paste', () => true)).status).toBe('refused') + expect((await pane.write(foreign, ticket, 'paste', () => true)).status).toBe('refused') + expect(pane.cancel(foreign, ticket).status).toBe('refused') + expect(pane.prepare(foreign, paste, () => true).status).toBe('refused') + expect(write).not.toHaveBeenCalled() + }) + it('consumes the paste phase before its mode await and keeps it consumed while Enter is pending', async () => { + const { pane, mode, write, prepare } = fixture() + let release!: (mode: boolean) => void + mode.mockReturnValueOnce(new Promise((resolve) => { release = resolve })) + const ticket = prepare(), first = pane.write(owner, ticket, 'paste', () => true) + expect((await pane.write(owner, ticket, 'paste', () => true)).status).toBe('refused') + release(true); expect((await first).status).toBe('awaiting-enter') + expect((await pane.write(owner, ticket, 'paste', () => true)).status).toBe('refused') + expect(write).toHaveBeenCalledTimes(1) + await waitForComposedEnter() + expect((await pane.write(owner, ticket, 'enter', () => true)).status).toBe('delivered') + expect(write).toHaveBeenCalledTimes(2) + }) + it('rechecks mode-barrier lifetime and expires preparation without ever writing', async () => { + const { pane, mode, write, prepare } = fixture() + let release!: (mode: boolean) => void, current = true + mode.mockReturnValueOnce(new Promise((resolve) => { release = resolve })) + const ticket = prepare(), result = pane.write(owner, ticket, 'paste', () => current) + current = false; release(true) + expect((await result).status).toBe('refused'); expect(write).not.toHaveBeenCalled() + vi.useFakeTimers() + const expired = prepare(); await vi.advanceTimersByTimeAsync(COMPOSED_TICKET_TTL_MS) + expect((await pane.write(owner, expired, 'paste', () => true)).status).toBe('refused') + expect(prepare()).not.toBe(expired) + }) + it('reports retirement, early Enter and throwing writes as uncertain after possible paste without replay', async () => { + for (const failure of ['retire', 'early', 'throw'] as const) { + const { pane, write, prepare } = fixture() + const ticket = prepare() + expect((await pane.write(owner, ticket, 'paste', () => true)).status).toBe('awaiting-enter') + if (failure === 'retire') pane.releaseOwner(owner) + if (failure === 'throw') { await waitForComposedEnter(); write.mockImplementationOnce(() => { throw new Error('native write lost') }) } + const result = await pane.write(owner, ticket, 'enter', () => true) + expect(result.status).toBe(failure === 'retire' ? 'refused' : 'uncertain') + expect((await pane.write(owner, ticket, 'paste', () => true)).status).toBe('refused') + expect(write).toHaveBeenCalledTimes(failure === 'throw' ? 2 : 1) + } + }) +}) diff --git a/src/core/composed-pty.ts b/src/core/composed-pty.ts new file mode 100644 index 0000000000..3e62a17664 --- /dev/null +++ b/src/core/composed-pty.ts @@ -0,0 +1,122 @@ +import { randomUUID } from 'node:crypto' +import { performance } from 'node:perf_hooks' +import { + COMPOSED_ENTER_DELAY_MS, COMPOSED_INPUT_UNCERTAIN, + normalizeComposedPaste, parseComposedInput, + type ComposedInput, type ComposedInputResult +} from '../shared/composed-input' + +export type ComposedPrepareResult = ComposedInputResult | { status: 'prepared'; ticket: string } +export type ComposedWriteResult = ComposedInputResult | { status: 'awaiting-enter' } +export const COMPOSED_TICKET_TTL_MS = 10_000 + +/** Timers can fire slightly before their requested duration. Keep the minimum monotonic. */ +export async function waitForComposedEnter(): Promise { + const until = performance.now() + COMPOSED_ENTER_DELAY_MS + for (;;) { + const remaining = until - performance.now() + if (remaining <= 0) return + await new Promise((resolve) => setTimeout(resolve, Math.ceil(remaining))) + } +} + +interface Pending { + owner: object + ticket: string + input: ComposedInput + phase: 'prepared' | 'writing' | 'awaiting-enter' + wrote: boolean + pastedAt: number + expiry: ReturnType +} + +const refused = (): ComposedInputResult => ({ status: 'refused', message: 'This terminal or Send action is no longer current.' }) +const uncertain = (): ComposedInputResult => ({ status: 'uncertain', message: COMPOSED_INPUT_UNCERTAIN }) + +/** One explicit action on an owned direct PTY. The host mints tickets rather than accepting + * replayable caller nonces: a consumed/expired ticket can never become another paste. The lock + * spans the separated Enter, including calls from other sockets. Ordinary raw keyboard input + * keeps its existing shared-terminal semantics. */ +export class ComposedPty { + private pending: Pending | null = null + private disposed = false + + constructor(private readonly pane: { bracketed(): Promise; write(data: string): void }) {} + + prepare(owner: object, value: unknown, current: () => boolean): ComposedPrepareResult { + const parsed = parseComposedInput(value) + if (!parsed || this.disposed || !current()) return refused() + if (this.pending) return { status: 'refused', message: 'Another Send action is still pending in this terminal.' } + const input = parsed.kind === 'paste' ? { ...parsed, text: normalizeComposedPaste(parsed.text) } : parsed + const ticket = randomUUID() + const expiry = setTimeout(() => { if (this.pending?.ticket === ticket) this.clear() }, COMPOSED_TICKET_TTL_MS) + expiry.unref?.() + this.pending = { owner, ticket, input, phase: 'prepared', wrote: false, pastedAt: 0, expiry } + return { status: 'prepared', ticket } + } + + async write(owner: object, ticket: string, phase: 'paste' | 'enter', current: () => boolean): Promise { + const held = this.pending + if (!held || held.owner !== owner || held.ticket !== ticket) return refused() + const valid = (): boolean => this.pending === held && !this.disposed && current() + if (!valid()) return this.cancel(owner, ticket) + if (phase === 'enter') { + if (held.phase !== 'awaiting-enter') return refused() + // Both the client and this owner enforce separation. An early/malformed commit must not + // turn a possibly pasted draft into a claimed refusal or type Enter in the paste burst. + if (performance.now() - held.pastedAt < COMPOSED_ENTER_DELAY_MS) { + this.clear() + return uncertain() + } + } else if (held.phase !== 'prepared') return refused() + held.phase = 'writing' // consume this phase before any asynchronous mode barrier + try { + let data: string + if (phase === 'enter') data = '\r' + else if (held.input.kind === 'control') data = held.input.text + else { + const body = held.input.text + const bracketed = body ? await this.pane.bracketed() : false + if (!valid()) return held.wrote ? uncertain() : refused() + data = body ? (bracketed ? `\x1b[200~${body}\x1b[201~` : body) : (held.input.enter ? '\r' : '') + } + if (!valid()) return held.wrote ? uncertain() : refused() + if (data) { + held.wrote = true // a throwing native write can already have accepted bytes + this.pane.write(data) + } + if (phase === 'paste' && held.input.kind === 'paste' && held.input.text && held.input.enter) { + held.pastedAt = performance.now() + held.phase = 'awaiting-enter' + return { status: 'awaiting-enter' } + } + this.clear() + return { status: 'delivered' } + } catch { + return held.wrote ? uncertain() : refused() + } finally { + if (this.pending === held && held.phase === 'writing') this.clear() + } + } + + cancel(owner: object, ticket: string): ComposedInputResult { + const held = this.pending + if (!held || held.owner !== owner || held.ticket !== ticket) return refused() + this.clear() + return held.wrote ? uncertain() : refused() + } + + releaseOwner(owner: object): void { + if (this.pending?.owner === owner) this.clear() + } + + dispose(): void { + this.disposed = true + this.clear() + } + + private clear(): void { + if (this.pending) clearTimeout(this.pending.expiry) + this.pending = null + } +} diff --git a/src/core/composed-tmux.realtmux.test.ts b/src/core/composed-tmux.realtmux.test.ts new file mode 100644 index 0000000000..1e790c8f06 --- /dev/null +++ b/src/core/composed-tmux.realtmux.test.ts @@ -0,0 +1,185 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { captureComposedViewer, composedSubmissionPlan, submitComposedTmux, type ComposedViewerReceipt } from './composed-tmux' +import { makeTmuxTmpdir } from './tmux-test-socket' + +const tmux = ['/usr/bin/tmux', '/usr/local/bin/tmux', '/opt/homebrew/bin/tmux', '/bin/tmux'].find((p) => fs.existsSync(p)) +const socket = `nt-composed-${process.pid}` +const supported = process.platform === 'linux' || process.platform === 'darwin' +let work: string, session: string, bytes: string, siblingBytes: string +let viewer: ChildProcessWithoutNullStreams | undefined +let receipt: ComposedViewerReceipt +let count = 0 +let alive = true +function env(): NodeJS.ProcessEnv { return { ...process.env, HOME: path.join(work, 'home'), TMUX_TMPDIR: work, TMUX: '', TMUX_PANE: '', LC_ALL: 'C', TERM: 'xterm-256color' } } +function run(args: string[], input?: string): string { + return execFileSync(tmux!, args, { env: env(), input, encoding: 'utf8', timeout: 3000, maxBuffer: 256 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }) +} +function command(args: string[]): string { return run(['-L', socket, ...args]) } +async function waitFor(test: () => boolean): Promise { + const deadline = Date.now() + 2000 + while (!test() && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10)) + expect(test()).toBe(true) +} +const boundary = () => ({ current: () => alive, run: async (args: string[], input?: string) => run(args, input) }) +function contents(file: string): string { return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '' } + +describe.skipIf(!tmux || !supported)('composed Send against its own real tmux viewer and panes', () => { + beforeAll(() => { + work = makeTmuxTmpdir('ntcs-', socket) + fs.mkdirSync(path.join(work, 'home')) + // Judge input at the application's raw tty. A composer that coalesces Enter within 100ms of + // paste-end records it as paste content instead of a submit. Bytes alone cannot prove this. + fs.writeFileSync(path.join(work, 'record'), `#!${process.execPath} +const fs = require('node:fs'); +const out = process.argv[2]; +process.stdin.setRawMode(true); +process.stdout.write('\\x1b[?2004h'); +fs.writeFileSync(out + '.ready', 'ready'); +let raw = Buffer.alloc(0), endAt = null; +process.stdin.on('data', chunk => { + const now = process.hrtime.bigint(); + fs.appendFileSync(out, chunk); + for (const byte of chunk) { + raw = Buffer.concat([raw, Buffer.from([byte])]); + fs.appendFileSync(out + '.times', JSON.stringify({ byte, ns: now.toString() }) + '\\n'); + const tail = raw.subarray(-6).toString('binary'); + if (tail === '\\x1b[200~') endAt = null; + if (tail === '\\x1b[201~') endAt = now; + if (byte === 13 && endAt !== null) { + const milliseconds = Number(now - endAt) / 1e6; + fs.appendFileSync(out + '.submits', JSON.stringify({ status: milliseconds >= 100 ? 'submitted' : 'coalesced', milliseconds }) + '\\n'); + } + } +}); +`, { mode: 0o755 }) + }) + beforeEach(async () => { + alive = true + session = `nt-composed-${process.pid}-${++count}` + bytes = path.join(work, `${count}.bytes`) + siblingBytes = path.join(work, `${count}.sibling`) + command(['-f', '/dev/null', 'new-session', '-d', '-s', session, '-x', '80', '-y', '24', `${path.join(work, 'record')} ${bytes}`]) + command(['new-window', '-d', '-t', `=${session}`, `${path.join(work, 'record')} ${siblingBytes}`]) + await waitFor(() => fs.existsSync(bytes + '.ready') && fs.existsSync(siblingBytes + '.ready')) + // A real tmux control-mode viewer needs no native node-pty binding. Its actual client process + // is attested by production code; ordinary local relay node-pty viewers use the same receipt. + viewer = spawn(tmux!, ['-L', socket, '-C', 'attach-session', '-t', `=${session}`], { env: env() }) + viewer.stdout.resume(); viewer.stderr.resume() + const captured = await captureComposedViewer(socket, session, viewer.pid!, boundary()) + expect(captured).not.toBeNull() + receipt = captured! + }) + afterEach(async () => { + alive = false + try { command(['detach-client', '-t', receipt.viewerName]) } catch { /* already detached */ } + if (viewer) { + viewer.stdin.end() + await waitFor(() => viewer!.exitCode !== null) + viewer = undefined + } + try { command(['kill-session', '-t', `=${session}`]) } catch { /* already ended */ } + }) + afterAll(() => { + if (work) { + try { command(['kill-server']) } catch { /* last session ended */ } + fs.rmSync(work, { recursive: true, force: true }) + } + }) + + it.each(['emacs', 'vi'])('leaves %s copy mode and sends one bracketed paste followed by a separate Enter', async (mode) => { + command(['set-option', '-w', '-t', receipt.paneId, 'mode-keys', mode]) + command(['copy-mode', '-t', receipt.paneId]) + expect(command(['display-message', '-p', '-t', receipt.paneId, '#{pane_in_mode}']).trim()).toBe('1') + const input = { kind: 'paste', text: 'first\r\nΩ 😀\x1b[201~', enter: true } as const + expect(await submitComposedTmux(receipt, input, boundary())).toEqual({ status: 'delivered' }) + const expected = '\x1b[200~first\rΩ 😀[201~\x1b[201~\r' + await waitFor(() => contents(bytes) === expected) + expect(command(['display-message', '-p', '-t', receipt.paneId, '#{pane_in_mode}']).trim()).toBe('0') + expect(contents(siblingBytes)).toBe('') + }) + it('preserves explicit Ctrl bytes without adding paste markers or Enter', async () => { + command(['copy-mode', '-t', receipt.paneId]) + expect(await submitComposedTmux(receipt, { kind: 'control', text: '\x03', enter: false }, boundary())).toEqual({ status: 'delivered' }) + await waitFor(() => contents(bytes) === '\x03') + expect(contents(siblingBytes)).toBe('') + }) + it('delays Enter after paste-end so a paste-coalescing consumer submits exactly once', async () => { + expect(await submitComposedTmux(receipt, { kind: 'paste', text: 'timed marker', enter: true }, boundary())).toEqual({ status: 'delivered' }) + await waitFor(() => fs.existsSync(bytes + '.submits')) + const submits = contents(bytes + '.submits').trim().split('\n').map((line) => JSON.parse(line)) + expect(submits).toHaveLength(1) + expect(submits[0].status).toBe('submitted') + expect(submits[0].milliseconds).toBeGreaterThanOrEqual(100) + expect(contents(bytes)).toBe('\x1b[200~timed marker\x1b[201~\r') + expect(contents(siblingBytes)).toBe('') + console.info(JSON.stringify({ fixture: 'owned real tmux paste-coalescing consumer', + pasteEndToEnterMs: submits[0].milliseconds, submissions: submits.length })) + }) + it.each(['detach', 'select'])('withholds Enter if the captured viewer changes during paste settling (%s)', async (change) => { + const result = await submitComposedTmux(receipt, { kind: 'paste', text: 'pending marker', enter: true }, { + ...boundary(), wait: async (ms) => { + if (change === 'detach') command(['detach-client', '-t', receipt.viewerName]) + else command(['select-window', '-t', `=${session}:1`]) + await new Promise((resolve) => setTimeout(resolve, ms)) + } + }) + expect(result.status).toBe('uncertain') + await waitFor(() => contents(bytes) === '\x1b[200~pending marker\x1b[201~') + expect(contents(bytes + '.submits')).toBe('') + expect(contents(siblingBytes)).toBe('') + }) + it('rechecks viewer presence inside the Enter queue after a late detach, preserving postpaste uncertainty', async () => { + const result = await submitComposedTmux(receipt, { kind: 'paste', text: 'late marker', enter: true }, { + ...boundary(), run: async (args, input) => { + if (input === '' && !args.includes('delete-buffer')) command(['detach-client', '-t', receipt.viewerName]) + return run(args, input) + } + }) + expect(result.status).toBe('uncertain') + await waitFor(() => contents(bytes) === '\x1b[200~late marker\x1b[201~') + expect(contents(bytes + '.submits')).toBe('') + expect(contents(siblingBytes)).toBe('') + }) + it('refuses changed pane identity and detached viewer inside the final tmux queue', () => { + for (const changed of [{ ...receipt, panePid: receipt.panePid + 1 }, + { ...receipt, serverPid: receipt.serverPid + 1 }, { ...receipt, sessionCreated: String(Number(receipt.sessionCreated) + 1) }]) { + const plan = composedSubmissionPlan(changed, { kind: 'paste', text: 'must not arrive', enter: false }) + expect(run(plan.args, plan.body).trim()).toBe('nt-composed-refused') + } + command(['detach-client', '-t', receipt.viewerName]) + const plan = composedSubmissionPlan(receipt, { kind: 'paste', text: 'must not arrive', enter: false }) + expect(run(plan.args, plan.body).trim()).toBe('nt-composed-refused') + expect(contents(bytes)).toBe('') + expect(contents(siblingBytes)).toBe('') + }) + it('refuses a pane respawn rather than sending to its replacement', async () => { + command(['respawn-pane', '-k', '-t', receipt.paneId, `${path.join(work, 'record')} ${bytes}`]) + expect((await submitComposedTmux(receipt, { kind: 'paste', text: 'replacement must stay empty', enter: true }, boundary())).status).toBe('refused') + expect(contents(bytes)).toBe('') + expect(contents(siblingBytes)).toBe('') + }) + it('refuses selected-window changes instead of following the new selected pane', async () => { + command(['select-window', '-t', `=${session}:1`]) + expect((await submitComposedTmux(receipt, { kind: 'paste', text: 'must not follow', enter: true }, boundary())).status).toBe('refused') + expect(contents(bytes)).toBe('') + expect(contents(siblingBytes)).toBe('') + }) + it('classifies a lost acknowledgment as uncertain without sending its payload again', async () => { + let deliveries = 0 + const result = await submitComposedTmux(receipt, { kind: 'paste', text: 'once', enter: true }, { + ...boundary(), run: async (args, input) => { + const output = run(args, input) + if (args.includes('load-buffer')) { deliveries++; throw new Error('simulated lost response after actual dispatch') } + return output + } + }) + expect(result.status).toBe('uncertain') + await waitFor(() => contents(bytes) === '\x1b[200~once\x1b[201~') + expect(contents(bytes + '.submits')).toBe('') + expect(deliveries).toBe(1) + expect(contents(siblingBytes)).toBe('') + }) +}) diff --git a/src/core/composed-tmux.test.ts b/src/core/composed-tmux.test.ts new file mode 100644 index 0000000000..c548e0b89f --- /dev/null +++ b/src/core/composed-tmux.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest' +import { captureComposedViewer, COMPOSED_VIEWER_FORMAT, composedSubmissionPlan, submitComposedTmux, + type ComposedTmuxBoundary, type ComposedViewerReceipt } from './composed-tmux' +import { COMPOSED_ENTER_DELAY_MS, COMPOSED_INPUT_MAX_BYTES, normalizeComposedPaste, parseComposedInput } from '../shared/composed-input' + +const row = '11|1700000000|nt-owned|client-11|99|1700000000|%7|77' +const birth = (pid: number): string => `linux:00000000-0000-0000-0000-000000000001:${pid}` +const receipt: ComposedViewerReceipt = { socket: 'owned-test', session: 'nt-owned', viewerPid: 11, + viewerCreated: '1700000000', viewerName: 'client-11', viewerBirth: birth(11), serverPid: 99, + serverBirth: birth(99), sessionCreated: '1700000000', paneId: '%7', panePid: 77, paneBirth: birth(77) } +const paste = { kind: 'paste', text: 'hello Ω', enter: true } as const + +function boundary(extra: Partial = {}) { + const run = vi.fn(async (args: string[]) => args.includes('list-clients') ? row : 'nt-composed-delivered\n') + const b: ComposedTmuxBoundary = { current: () => true, run, readBirth: async (pid) => birth(pid), wait: async () => {}, ...extra } + return { b, run } +} + +describe('composed input validation', () => { + it('accepts bounded Unicode pastes and precisely one explicit Ctrl byte without Enter', () => { + expect(parseComposedInput(paste)).toEqual(paste) + for (let i = 0; i < 32; i++) expect(parseComposedInput({ kind: 'control', text: String.fromCharCode(i), enter: false })).not.toBeNull() + expect(parseComposedInput({ kind: 'control', text: '\x7f', enter: false })).not.toBeNull() + for (const input of [null, {}, { ...paste, enter: 1 }, { ...paste, text: '\x00' }, { ...paste, text: 'Ω'.repeat(COMPOSED_INPUT_MAX_BYTES) }, + { ...paste, text: 'x'.repeat(COMPOSED_INPUT_MAX_BYTES + 1) }, { kind: 'control', text: 'a', enter: false }, + { kind: 'control', text: '\x03\r', enter: false }, { kind: 'control', text: '\x03', enter: true }]) + expect(parseComposedInput(input)).toBeNull() + }) + it('normalizes xterm paste line endings and prevents payload-supplied paste structure', () => { + expect(normalizeComposedPaste('a\r\nb\nc\r\x1b[201~\u009b200~')).toBe('a\rb\rc\r[201~200~') + }) +}) + +describe('attachment-time composed viewer receipt', () => { + it('attests the actual attached client, server and pane twice before pinning', async () => { + const { b, run } = boundary() + const read = vi.fn(async (pid: number) => birth(pid)) + expect(await captureComposedViewer('owned-test', 'nt-owned', 11, { ...b, readBirth: read })).toEqual(receipt) + expect(read.mock.calls.map(([pid]) => pid)).toEqual([11, 99, 77]) + expect(run.mock.calls.map(([args]) => args)).toEqual([0, 1].map(() => + ['-L', 'owned-test', 'list-clients', '-t', '=nt-owned', '-F', COMPOSED_VIEWER_FORMAT])) + }) + it('waits only for registration of its spawned viewer and never adopts a neighbour', async () => { + const { b } = boundary() + const run = vi.fn().mockResolvedValueOnce(row.replace(/^11\|/, '12|')).mockResolvedValue(row) + expect(await captureComposedViewer('owned-test', 'nt-owned', 11, { ...b, run })).toEqual(receipt) + expect(run).toHaveBeenCalledTimes(3) + }) + it('refuses cancellation and a pane replacement during attachment attestation', async () => { + const { b } = boundary() + const run = vi.fn().mockResolvedValueOnce(row).mockResolvedValue(row.replace('%7|77', '%8|88')) + expect(await captureComposedViewer('owned-test', 'nt-owned', 11, { ...b, run })).toBeNull() + expect(await captureComposedViewer('owned-test', 'nt-owned', 11, { ...b, current: () => false })).toBeNull() + expect(await captureComposedViewer('owned-test', 'nt-owned', 11, { ...b, run: async () => row + '\n' + row })).toBeNull() + }) +}) + +describe('generation-bound composed submission', () => { + it('writes normalized content only on stdin, with exact pane framing, separate Enter and final viewer gate', async () => { + const { b, run } = boundary() + const input = { ...paste, text: 'a\r\nb\x1b[201~' } + expect(await submitComposedTmux(receipt, input, b)).toEqual({ status: 'delivered' }) + const dispatch = run.mock.calls[1] as unknown as [string[], string] + expect(dispatch[1]).toBe('a\rb[201~') + expect(dispatch[0].join(' ')).not.toContain(input.text) + const yes = dispatch[0].at(-2)! + expect(yes).toContain("if-shell -F -t %7 '#{pane_in_mode}' 'send-keys -t %7 -X cancel'") + expect(yes).toMatch(/paste-buffer -d -p -r -b nt-paste-[a-z0-9-]+ -t %7/) + expect(yes).not.toContain('Enter') + expect(dispatch[0].at(-3)).toContain('#{L:') + expect(dispatch[0].at(-3)).toContain('#{==:#{client_pid},11}') + const enter = run.mock.calls[3] as unknown as [string[], string] + expect(enter[1]).toBe('') + expect(enter[0].at(-2)).toContain('send-keys -t %7 Enter') + expect(enter[0].at(-3)).toBe(dispatch[0].at(-3)) + }) + it('waits after confirmed paste, then treats retirement before Enter as uncertain without a second write', async () => { + let alive = true + const wait = vi.fn(async () => { alive = false }) + const { b, run } = boundary({ current: () => alive, wait }) + expect((await submitComposedTmux(receipt, paste, b)).status).toBe('uncertain') + expect(wait).toHaveBeenCalledWith(COMPOSED_ENTER_DELAY_MS) + expect(run.mock.calls.filter(([args]) => args.includes('load-buffer'))).toHaveLength(1) + expect(run.mock.calls.filter(([args]) => args.at(-2)?.includes('Enter'))).toHaveLength(0) + }) + it('rechecks process birth before Enter and never calls a postpaste refusal unsent', async () => { + let changed = false + const { b, run } = boundary({ wait: async () => { changed = true }, readBirth: async (pid) => changed && pid === 77 ? birth(pid + 1) : birth(pid) }) + expect((await submitComposedTmux(receipt, paste, b)).status).toBe('uncertain') + expect(run.mock.calls.filter(([args]) => args.at(-2)?.includes('Enter'))).toHaveLength(0) + expect(run.mock.calls.filter(([args]) => args.includes('load-buffer'))).toHaveLength(1) + }) + it('maps the final Enter queue refusal to uncertain, keeping the completed paste single-shot', async () => { + const { b } = boundary() + const run = vi.fn(async (args: string[], body?: string) => { + if (args.includes('list-clients')) return row + return body === '' ? 'nt-composed-refused' : 'nt-composed-delivered' + }) + expect((await submitComposedTmux(receipt, paste, { ...b, run })).status).toBe('uncertain') + expect(run.mock.calls.filter(([args]) => args.includes('load-buffer'))).toHaveLength(1) + expect(run.mock.calls.filter(([args]) => args.at(-2)?.includes('Enter'))).toHaveLength(1) + }) + it.each(['viewerPid', 'viewerCreated', 'viewerName', 'serverPid', 'sessionCreated', 'paneId', 'panePid'] as const) + ('refuses changed %s before payload dispatch', async (field) => { + const changed = { ...receipt, [field]: typeof receipt[field] === 'number' ? Number(receipt[field]) + 1 : String(receipt[field]) + '1' } + const { b, run } = boundary() + expect((await submitComposedTmux(changed, paste, b)).status).toBe('refused') + expect(run.mock.calls.every(([args]) => !args.includes('load-buffer'))).toBe(true) + }) + it.each([11, 99, 77])('refuses reused PID/birth %s before payload dispatch', async (pid) => { + const { b, run } = boundary({ readBirth: async (p) => p === pid ? birth(p + 100) : birth(p) }) + expect((await submitComposedTmux(receipt, paste, b)).status).toBe('refused') + expect(run).toHaveBeenCalledTimes(1) + }) + it('refuses a detach while attestation is suspended', async () => { + let alive = true + const { b, run } = boundary({ current: () => alive, readBirth: async (pid) => { alive = false; return birth(pid) } }) + expect((await submitComposedTmux(receipt, paste, b)).status).toBe('refused') + expect(run).toHaveBeenCalledTimes(1) + }) + it.each(['missing', 'throws'])('reports %s dispatch acknowledgment as uncertain without replay', async (mode) => { + const { b } = boundary() + const run = vi.fn(async (args: string[]) => { + if (args.includes('list-clients')) return row + if (args.includes('load-buffer')) { if (mode === 'throws') throw new Error('lost response'); return '' } + return '' + }) + expect((await submitComposedTmux(receipt, paste, { ...b, run })).status).toBe('uncertain') + expect(run.mock.calls.filter(([args]) => args.includes('load-buffer'))).toHaveLength(1) + expect(run.mock.calls.filter(([args]) => args.includes('delete-buffer'))).toHaveLength(1) + }) + it('keeps Ctrl unframed and preserves the empty body/Enter contract with unique buffers', () => { + const ctrl = composedSubmissionPlan(receipt, { kind: 'control', text: '\x03', enter: false }) + expect(ctrl.body).toBe('') + expect(ctrl.args.join(' ')).toContain('send-keys -t %7 -H 03') + expect(ctrl.args.join(' ')).not.toContain('Enter') + expect(ctrl.args).not.toContain('load-buffer') + const empty = composedSubmissionPlan(receipt, { kind: 'paste', text: '', enter: true }) + expect(empty.args.join(' ')).toContain('send-keys -t %7 Enter') + expect(empty.args).not.toContain('load-buffer') + expect(() => composedSubmissionPlan(receipt, paste)).toThrow('separate guarded dispatches') + const a = composedSubmissionPlan(receipt, { ...paste, enter: false }), b = composedSubmissionPlan(receipt, { ...paste, enter: false }) + expect(a.args[4]).not.toBe(b.args[4]) + }) +}) diff --git a/src/core/composed-tmux.ts b/src/core/composed-tmux.ts new file mode 100644 index 0000000000..2f3846f01d --- /dev/null +++ b/src/core/composed-tmux.ts @@ -0,0 +1,175 @@ +import { performance } from 'node:perf_hooks' +import { readManagedProcessBirth } from './managed-pane' +import { isSessionName, pasteBufferName } from './tmux-naming' +import { COMPOSED_ENTER_DELAY_MS, COMPOSED_INPUT_UNCERTAIN, normalizeComposedPaste, parseComposedInput, + type ComposedInput, type ComposedInputResult } from '../shared/composed-input' + +export const COMPOSED_VIEWER_FORMAT = '#{client_pid}|#{client_created}|#{session_name}|#{client_name}|#{pid}|#{session_created}|#{pane_id}|#{pane_pid}' +const DELIVERED = 'nt-composed-delivered' +const REFUSED = 'nt-composed-refused' +const STALE = 'This terminal viewer or its pane changed. Reattach before sending.' + +export interface ComposedViewerReceipt { + socket: string + session: string + viewerPid: number + viewerCreated: string + viewerName: string + viewerBirth: string + serverPid: number + serverBirth: string + sessionCreated: string + paneId: string + panePid: number + paneBirth: string +} + +export interface ComposedTmuxBoundary { + current(): boolean + run(args: string[], input?: string): Promise + readBirth?(pid: number): Promise + wait?(ms: number): Promise +} + +type ViewerFields = Omit + +function viewerFields(raw: string, session: string, pid: number): ViewerFields | null { + const found: ViewerFields[] = [] + for (const row of raw.trim().split('\n')) { + const [viewer, created, ownSession, name, server, sessionCreated, pane, panePid, ...extra] = row.split('|') + if (extra.length || viewer !== String(pid) || ownSession !== session || + !/^[1-9][0-9]*$/.test(created) || !/^[1-9][0-9]*$/.test(server) || + !/^[1-9][0-9]*$/.test(sessionCreated) || !/^%[0-9]+$/.test(pane) || + !/^[1-9][0-9]*$/.test(panePid) || !/^(?:\/[A-Za-z0-9_./-]+|client-[0-9]+)$/.test(name)) continue + if (!Number.isSafeInteger(Number(server)) || !Number.isSafeInteger(Number(panePid))) continue + found.push({ session, viewerPid: pid, viewerCreated: created, viewerName: name, + serverPid: Number(server), sessionCreated, paneId: pane, panePid: Number(panePid) }) + } + return found.length === 1 ? found[0] : null +} + +function sameFields(a: ViewerFields, b: ViewerFields): boolean { + return a.session === b.session && a.viewerPid === b.viewerPid && a.viewerCreated === b.viewerCreated && + a.viewerName === b.viewerName && a.serverPid === b.serverPid && a.sessionCreated === b.sessionCreated && + a.paneId === b.paneId && a.panePid === b.panePid +} + +function validBoundary(socket: string, session: string, pid: number): boolean { + return /^[A-Za-z0-9_-]+$/.test(socket) && isSessionName(session) && Number.isSafeInteger(pid) && pid > 0 +} + +function list(r: Pick): string[] { + return ['-L', r.socket, 'list-clients', '-t', `=${r.session}`, '-F', COMPOSED_VIEWER_FORMAT] +} + +/** Pin the actual spawned local tmux client and its pane during attachment, never on first Send. */ +export async function captureComposedViewer( + socket: string, session: string, viewerPid: number, b: ComposedTmuxBoundary +): Promise { + if (!validBoundary(socket, session, viewerPid)) return null + const readBirth = b.readBirth ?? readManagedProcessBirth + const wait = b.wait ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + const deadline = performance.now() + 2000 + try { + // node-pty returns before the new client has registered. Only registration may be waited for; + // once a matching viewer is found, an inconsistent read refuses rather than choosing another. + for (let attempt = 0; attempt < 40 && b.current() && performance.now() < deadline; attempt++) { + let fields: ViewerFields | null = null + try { fields = viewerFields(await b.run(list({ socket, session })), session, viewerPid) } catch { /* not attached yet */ } + if (!b.current()) return null + if (!fields) { await wait(25); continue } + const [viewerBirth, serverBirth, paneBirth] = await Promise.all([ + readBirth(viewerPid), readBirth(fields.serverPid), readBirth(fields.panePid) + ]) + if (!b.current()) return null + const after = viewerFields(await b.run(list({ socket, session })), session, viewerPid) + if (!after || !sameFields(fields, after) || !b.current()) return null + return Object.freeze({ socket, ...fields, viewerBirth, serverBirth, paneBirth }) + } + } catch { /* an unattested generation cannot be typed into */ } + return null +} + +function and(parts: string[]): string { return parts.reduce((a, b) => `#{&&:${a},${b}}`) } + +/** Final gate is evaluated inside tmux's own queue, including the captured client's presence. */ +export function composedSubmissionPlan(r: ComposedViewerReceipt, value: ComposedInput): + { args: string[]; body: string; cleanup: string[] | null } { + const input = parseComposedInput(value) + if (!input || !validBoundary(r.socket, r.session, r.viewerPid) || !/^%[0-9]+$/.test(r.paneId) || + !Number.isSafeInteger(r.panePid) || r.panePid < 1 || !Number.isSafeInteger(r.serverPid) || r.serverPid < 1 || + !/^[1-9][0-9]*$/.test(r.sessionCreated) || !/^[1-9][0-9]*$/.test(r.viewerCreated) || + !/^(?:\/[A-Za-z0-9_./-]+|client-[0-9]+)$/.test(r.viewerName)) throw new Error('Invalid composed terminal identity') + const viewer = and([ + `#{==:#{client_pid},${r.viewerPid}}`, `#{==:#{client_created},${r.viewerCreated}}`, + `#{==:#{client_session},${r.session}}`, `#{==:#{client_name},${r.viewerName}}`, + `#{==:#{pid},${r.serverPid}}`, `#{==:#{pane_id},${r.paneId}}`, `#{==:#{pane_pid},${r.panePid}}` + ]) + const condition = and([ + `#{==:#{pid},${r.serverPid}}`, `#{==:#{session_name},${r.session}}`, + `#{==:#{session_created},${r.sessionCreated}}`, `#{==:#{pane_id},${r.paneId}}`, + `#{==:#{pane_pid},${r.panePid}}`, `#{L:#{?${viewer},1,}}` + ]) + const body = input.kind === 'paste' ? normalizeComposedPaste(input.text) : '' + if (body && input.enter) throw new Error('Composed paste and Enter require separate guarded dispatches') + const buffer = body ? pasteBufferName() : null + const yes: string[] = [`if-shell -F -t ${r.paneId} '#{pane_in_mode}' 'send-keys -t ${r.paneId} -X cancel'`] + if (input.kind === 'control') yes.push(`send-keys -t ${r.paneId} -H ${input.text.charCodeAt(0).toString(16).padStart(2, '0')}`) + else { + if (buffer) yes.push(`paste-buffer -d -p -r -b ${buffer} -t ${r.paneId}`) + if (input.enter) yes.push(`send-keys -t ${r.paneId} Enter`) + } + yes.push(`display-message -p ${DELIVERED}`) + const no = `${buffer ? `delete-buffer -b ${buffer} ; ` : ''}display-message -p ${REFUSED}` + const args = ['-L', r.socket] + if (buffer) args.push('load-buffer', '-b', buffer, '-', ';') + args.push('if-shell', '-F', '-t', r.paneId, condition, yes.join(' ; '), no) + return { args, body, cleanup: buffer ? ['-L', r.socket, 'delete-buffer', '-b', buffer] : null } +} + +/** One guarded dispatch. Payload loss is uncertain and never replayed. */ +async function dispatchComposedTmux( + r: ComposedViewerReceipt, input: ComposedInput, b: ComposedTmuxBoundary +): Promise { + const refused = (): ComposedInputResult => ({ status: 'refused', message: STALE }) + const readBirth = b.readBirth ?? readManagedProcessBirth + let plan: ReturnType + try { + plan = composedSubmissionPlan(r, input) + if (!b.current()) return refused() + const actual = viewerFields(await b.run(list(r)), r.session, r.viewerPid) + if (!actual || !sameFields(r, actual) || !b.current()) return refused() + const births = await Promise.all([readBirth(r.viewerPid), readBirth(r.serverPid), readBirth(r.panePid)]) + if (births[0] !== r.viewerBirth || births[1] !== r.serverBirth || births[2] !== r.paneBirth || !b.current()) return refused() + } catch { return refused() } + try { + // run consumes stdin and executes once. A lost response can follow completed input. + const result = (await b.run(plan.args, plan.body)).trim() + if (result === DELIVERED) return { status: 'delivered' } + if (result === REFUSED) return refused() + } catch { /* uncertain after dispatch */ } + if (plan.cleanup) void Promise.resolve(b.run(plan.cleanup, '')).catch(() => {}) + return { status: 'uncertain', message: COMPOSED_INPUT_UNCERTAIN } +} + +/** Paste and Enter are separated in time, with the exact captured generation gated at EACH write. */ +export async function submitComposedTmux( + r: ComposedViewerReceipt, value: ComposedInput, b: ComposedTmuxBoundary +): Promise { + const input = parseComposedInput(value) + if (!input) return { status: 'refused', message: 'Invalid composed terminal input.' } + if (input.kind !== 'paste' || !input.enter || normalizeComposedPaste(input.text).length === 0) + return dispatchComposedTmux(r, input, b) + const pasted = await dispatchComposedTmux(r, { ...input, enter: false }, b) + if (pasted.status !== 'delivered') return pasted + // A successful paste alone is not a successful submit. An Enter coalesced into the paste was + // the input bar's original false-success hazard, so retain its 150ms settling time. The queue + // holds this action across the wait; a detach/replacement then prevents the second write. + const wait = b.wait ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + try { + await wait(COMPOSED_ENTER_DELAY_MS) + const submitted = await dispatchComposedTmux(r, { kind: 'paste', text: '', enter: true }, b) + if (submitted.status === 'delivered') return submitted + } catch { /* the paste has already landed, so every subsequent failure is uncertain */ } + return { status: 'uncertain', message: COMPOSED_INPUT_UNCERTAIN } +} diff --git a/src/core/exec-path.test.ts b/src/core/exec-path.test.ts index b78065edb0..66f0275e50 100644 --- a/src/core/exec-path.test.ts +++ b/src/core/exec-path.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import fs from 'fs' import os from 'os' import path from 'path' @@ -11,6 +11,205 @@ import { unquotePathEntry } from './exec-path' +const shellProbe = vi.hoisted(() => ({ execFile: vi.fn() })) +vi.mock('child_process', async (original) => ({ + ...(await original()), + execFile: shellProbe.execFile +})) + +describe('login-shell probe completion', () => { + type Callback = (error: Error | null, stdout: string, stderr: string) => void + let probes: Array<{ + callback: Callback + child: { + exitCode: number | null + signalCode: NodeJS.Signals | null + kill: ReturnType + stdin: { destroy: ReturnType } + stdout: { destroy: ReturnType } + stderr: { destroy: ReturnType } + } + }> + + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + vi.spyOn(os, 'platform').mockReturnValue('linux') + vi.stubEnv('SHELL', '/controlled/login-shell') + probes = [] + shellProbe.execFile.mockReset() + shellProbe.execFile.mockImplementation((_file, _args, _options, callback: Callback) => { + const child = { + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + kill: vi.fn(() => true), + stdin: { destroy: vi.fn() }, + stdout: { destroy: vi.fn() }, + stderr: { destroy: vi.fn() } + } + probes.push({ callback, child }) + return child + }) + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('parses profile noise and caches a successful PATH without another shell', async () => { + const api = await import('./exec-path') + const pending = api.resolveShellPath() + expect(shellProbe.execFile).toHaveBeenCalledWith( + '/controlled/login-shell', + ['-ilc', expect.stringContaining('"$PATH"')], + expect.objectContaining({ encoding: 'utf-8', timeout: 5000 }), + expect.any(Function) + ) + probes[0].callback(null, 'profile noise\n__NT_PATH_START__ /tools:/bin __NT_PATH_END__\nprompt', '') + await expect(pending).resolves.toBe('/tools:/bin') + expect(api.shellPathNow()).toBe('/tools:/bin') + await vi.advanceTimersByTimeAsync(10000) + await expect(api.resolveShellPath()).resolves.toBe('/tools:/bin') + expect(probes).toHaveLength(1) + expect(probes[0].child.kill).not.toHaveBeenCalled() + }) + + it('bounds a never-completing PATH probe, shares its fallback and ignores late success', async () => { + const api = await import('./exec-path') + const first = api.resolveShellPath() + const second = api.resolveShellPath() + expect(first).toBe(second) + let completed = false + void first.then(() => { completed = true }) + await vi.advanceTimersByTimeAsync(4999) + expect(completed).toBe(false) + expect(api.shellPathNow()).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + // Assert completion before awaiting: deleting the real deadline must fail an assertion, + // rather than being mistaken for an infrastructure/test timeout. + expect(completed).toBe(true) + await expect(first).resolves.toBeNull() + await expect(second).resolves.toBeNull() + expect(api.shellPathNow()).toBeNull() + expect(probes[0].child.kill).toHaveBeenCalledExactlyOnceWith('SIGKILL') + for (const stream of ['stdin', 'stdout', 'stderr'] as const) { + expect(probes[0].child[stream].destroy).toHaveBeenCalledOnce() + } + probes[0].callback(null, '__NT_PATH_START__/late/tools__NT_PATH_END__', '') + await vi.advanceTimersByTimeAsync(10000) + await expect(api.resolveShellPath()).resolves.toBeNull() + expect(api.shellPathNow()).toBeNull() + expect(probes).toHaveLength(1) + }) + + it('uses inherited PATH after the deadline instead of blocking executable lookup', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-shell-fallback-')) + try { + const bin = path.join(dir, 'owned-probe') + fs.writeFileSync(bin, '', { mode: 0o755 }) + vi.stubEnv('PATH', dir) + const api = await import('./exec-path') + const lookup = api.findInLoginPath('owned-probe') + let result: string | null | undefined + void lookup.then((value) => { result = value }) + await vi.advanceTimersByTimeAsync(5000) + expect(result).toBe(bin) + await expect(lookup).resolves.toBe(bin) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it('bounds each variable independently, coalesces callers and never adopts a late value', async () => { + const api = await import('./exec-path') + const first = api.resolveShellEnvVar('OWNED_TEST_VAR') + expect(api.resolveShellEnvVar('OWNED_TEST_VAR')).toBe(first) + const other = api.resolveShellEnvVar('OTHER_TEST_VAR') + probes[1].callback(null, 'noise__NT_VAR_START__ live value __NT_VAR_END__', '') + await expect(other).resolves.toBe('live value') + let completed = false + void first.then(() => { completed = true }) + await vi.advanceTimersByTimeAsync(5000) + expect(completed).toBe(true) + await expect(first).resolves.toBeNull() + probes[0].callback(null, '__NT_VAR_START__late value__NT_VAR_END__', '') + await vi.advanceTimersByTimeAsync(1) + await expect(api.resolveShellEnvVar('OWNED_TEST_VAR')).resolves.toBeNull() + await expect(api.resolveShellEnvVar('OTHER_TEST_VAR')).resolves.toBe('live value') + expect(probes).toHaveLength(2) + expect(probes[0].child.kill).toHaveBeenCalledExactlyOnceWith('SIGKILL') + expect(probes[1].child.kill).not.toHaveBeenCalled() + }) + + it.each([ + { exitCode: 0, signalCode: null }, + { exitCode: null, signalCode: 'SIGTERM' as const } + ])('releases held pipes without signalling an exited child (%j)', async (exit) => { + const api = await import('./exec-path') + const pending = api.resolveShellPath() + Object.assign(probes[0].child, exit) + let completed = false + void pending.then(() => { completed = true }) + await vi.advanceTimersByTimeAsync(5000) + expect(completed).toBe(true) + await expect(pending).resolves.toBeNull() + expect(probes[0].child.kill).not.toHaveBeenCalled() + expect(probes[0].child.stdout.destroy).toHaveBeenCalledOnce() + expect(probes[0].child.stderr.destroy).toHaveBeenCalledOnce() + }) + + it('settles even when owned-child cleanup throws, and releases the remaining pipes', async () => { + const api = await import('./exec-path') + const pending = api.resolveShellPath() + probes[0].child.kill.mockImplementation(() => { throw new Error('owned child unavailable') }) + probes[0].child.stdin.destroy.mockImplementation(() => { throw new Error('already closed') }) + let completed = false + void pending.then(() => { completed = true }) + await vi.advanceTimersByTimeAsync(5000) + expect(completed).toBe(true) + await expect(pending).resolves.toBeNull() + expect(probes[0].child.stdout.destroy).toHaveBeenCalledOnce() + expect(probes[0].child.stderr.destroy).toHaveBeenCalledOnce() + }) + + it('falls back immediately on callback errors and synchronous spawn errors', async () => { + const api = await import('./exec-path') + const failed = api.resolveShellPath() + probes[0].callback(new Error('profile failed'), '', '') + await expect(failed).resolves.toBeNull() + shellProbe.execFile.mockImplementationOnce(() => { throw new Error('cannot spawn') }) + await expect(api.resolveShellEnvVar('OWNED_TEST_VAR')).resolves.toBeNull() + await vi.advanceTimersByTimeAsync(10000) + expect(probes[0].child.kill).not.toHaveBeenCalled() + }) + + it('admits an early callback before execFile returns the child', async () => { + const normalSpawn = shellProbe.execFile.getMockImplementation()! + shellProbe.execFile.mockImplementationOnce((file, args, options, callback: Callback) => { + const child = normalSpawn(file, args, options, callback) + callback(null, '__NT_PATH_START__/early/tools__NT_PATH_END__', '') + return child + }) + const api = await import('./exec-path') + await expect(api.resolveShellPath()).resolves.toBe('/early/tools') + await vi.advanceTimersByTimeAsync(10000) + expect(probes[0].child.kill).not.toHaveBeenCalled() + expect(probes[0].child.stdout.destroy).not.toHaveBeenCalled() + }) + + it('keeps Windows and invalid variable names subprocess-free', async () => { + const api = await import('./exec-path') + await expect(api.resolveShellEnvVar('UNSAFE;COMMAND')).resolves.toBeNull() + expect(probes).toHaveLength(0) + vi.spyOn(os, 'platform').mockReturnValue('win32') + await expect(api.resolveShellPath()).resolves.toBeNull() + await expect(api.resolveShellEnvVar('OWNED_TEST_VAR')).resolves.toBeNull() + expect(api.shellPathNow()).toBeNull() + expect(probes).toHaveLength(0) + }) +}) + describe('executableCandidates', () => { it('leaves a bare name alone off win32 — POSIX has no PATHEXT', () => { expect(executableCandidates('gh', 'darwin', undefined)).toEqual(['gh']) @@ -209,6 +408,39 @@ describe('findInPathString (real filesystem)', () => { expect(findInPathString('nt-absent', dir)).toBeNull() }) + it('resolves an absolute executable without requiring its directory on PATH', () => { + const bin = writeBin(process.platform === 'win32' ? 'nt-absolute.exe' : 'nt-absolute') + expectPath(findInPathString(bin, ''), bin) + expectPath(findInPathString(bin, path.join(dir, 'unrelated')), bin) + }) + + it('rejects an absent absolute executable', () => { + const bin = path.join(dir, 'absent', process.platform === 'win32' ? 'nt-absent.exe' : 'nt-absent') + expect(findInPathString(bin, dir)).toBeNull() + }) + + it.skipIf(process.platform === 'win32')('rejects an absent absolute executable even if PATH contains a joined shadow', () => { + // Windows cannot represent the old joined result with a second drive letter in the middle. + const bin = path.join(dir, 'absent', process.platform === 'win32' ? 'nt-shadow.exe' : 'nt-shadow') + const pathEntry = path.join(dir, 'path-entry') + // The old lookup joined even absolute programs to PATH, admitting this unrelated file. + const shadow = path.join(pathEntry, bin) + fs.mkdirSync(path.dirname(shadow), { recursive: true }) + fs.writeFileSync(shadow, '', { mode: 0o755 }) + expect(findInPathString(bin, pathEntry)).toBeNull() + }) + + it('rejects a directory supplied as an absolute executable', () => { + expect(findInPathString(dir, '')).toBeNull() + }) + + it.skipIf(process.platform === 'win32')('rejects a non-executable absolute file', () => { + // Windows checks existence instead of POSIX executable permissions. + const bin = path.join(dir, 'nt-no-exec') + fs.writeFileSync(bin, '', { mode: 0o644 }) + expect(findInPathString(bin, '')).toBeNull() + }) + it('skips empty entries and searches later ones', () => { const bin = process.platform === 'win32' ? 'nt-probe.exe' : 'nt-probe' writeBin(bin) diff --git a/src/core/exec-path.ts b/src/core/exec-path.ts index 619272a6de..2fc471cca0 100644 --- a/src/core/exec-path.ts +++ b/src/core/exec-path.ts @@ -15,10 +15,47 @@ import fs from 'fs' import os from 'os' import path from 'path' -import { execFile } from 'child_process' -import { promisify } from 'util' +import { execFile, type ChildProcess } from 'child_process' -const runAsync = promisify(execFile) +const SHELL_PROBE_TIMEOUT_MS = 5000 + +/** execFile's timeout only sends a signal: its callback can still wait forever for close. + * Settle independently, then release only this probe's child and pipes. No PID/group lookup: + * an exited shell's descendants may hold stdout, but must never become cleanup targets. */ +function probeShell(shell: string, command: string): Promise { + return new Promise((resolve) => { + let child: ChildProcess | undefined + let settled = false + const finish = (stdout: string | null): boolean => { + if (settled) return false + settled = true + clearTimeout(deadline) + resolve(stdout) + return true + } + const deadline = setTimeout(() => { + if (!finish(null) || !child) return + // kill() belongs to the returned ChildProcess, and its exit fields prevent signalling + // an already-reaped child whose old PID could now name an unrelated process. + if (child.exitCode === null && child.signalCode === null) { + try { child.kill('SIGKILL') } catch { /* best effort; fallback is already settled */ } + } + for (const stream of [child.stdin, child.stdout, child.stderr]) { + try { stream?.destroy() } catch { /* one pipe must not prevent releasing the others */ } + } + }, SHELL_PROBE_TIMEOUT_MS) + try { + child = execFile(shell, ['-ilc', command], { + encoding: 'utf-8', + timeout: SHELL_PROBE_TIMEOUT_MS + }, (error, stdout) => { + finish(error ? null : stdout) + }) + } catch { + finish(null) + } + }) +} /** * Resolve the user's REAL login-shell PATH once, and cache it. @@ -45,12 +82,9 @@ export function resolveShellPath(): Promise { // Dotfiles routinely take hundreds of ms (nvm/conda init) and can hang, so this MUST be // async — a synchronous probe here froze every window and all IPC for up to the 5s timeout. // stderr is captured separately by execFile, so prompt/compinit noise can't pollute stdout. - shellPathPromise = runAsync(shell, ['-ilc', `command printf '${START}%s${END}' "$PATH"`], { - encoding: 'utf-8', - timeout: 5000 - }) - .then(({ stdout }) => { - const m = stdout.match(new RegExp(`${START}([\\s\\S]*?)${END}`)) + shellPathPromise = probeShell(shell, `command printf '${START}%s${END}' "$PATH"`) + .then((stdout) => { + const m = stdout?.match(new RegExp(`${START}([\\s\\S]*?)${END}`)) return m?.[1]?.trim() || null }) .catch(() => null) // login shell hung / errored / isn't POSIX — inherited-PATH fallback @@ -83,12 +117,9 @@ export function resolveShellEnvVar(name: string): Promise { const shell = process.env.SHELL || '/bin/bash' const START = '__NT_VAR_START__' const END = '__NT_VAR_END__' - const p = runAsync(shell, ['-ilc', `command printf '${START}%s${END}' "$${name}"`], { - encoding: 'utf-8', - timeout: 5000 - }) - .then(({ stdout }) => { - const m = stdout.match(new RegExp(`${START}([\\s\\S]*?)${END}`)) + const p = probeShell(shell, `command printf '${START}%s${END}' "$${name}"`) + .then((stdout) => { + const m = stdout?.match(new RegExp(`${START}([\\s\\S]*?)${END}`)) return m?.[1]?.trim() || null }) .catch(() => null) @@ -169,9 +200,18 @@ export function unquotePathEntry(entry: string): string { * harmless but says something untrue about the check; F_OK says what we actually test. */ const ACCESS_MODE = os.platform() === 'win32' ? fs.constants.F_OK : fs.constants.X_OK -/** Walk a PATH string for an executable — sync but SUBPROCESS-FREE (one accessSync per candidate), - * so it is safe on the main thread. Returns the first accessible match, or null. */ +/** Resolve an absolute executable directly, or walk PATH for a bare name — sync but + * SUBPROCESS-FREE, so it is safe on the main thread. An unavailable absolute path never falls + * through to PATH: joining it to a PATH entry would check an unrelated executable. */ export function findInPathString(bin: string, pathStr: string | null | undefined): string | null { + if (path.isAbsolute(bin)) { + try { + fs.accessSync(bin, ACCESS_MODE) + return fs.statSync(bin).isFile() ? bin : null + } catch { + return null + } + } const names = executableCandidates(bin, os.platform(), process.env.PATHEXT) for (const raw of (pathStr ?? '').split(path.delimiter)) { const dir = unquotePathEntry(raw) diff --git a/src/core/held-hook-answers.test.ts b/src/core/held-hook-answers.test.ts new file mode 100644 index 0000000000..49f9432a5c --- /dev/null +++ b/src/core/held-hook-answers.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { normalizeClaude } from '../shared/agents/normalize' +import { _resetForTest, _inboxSnapshot, _snapshot, recordAgentEvent, recordRawToolEvent, + hookTicketStillOpen, onNodeStateChange, type NodeStateChange } from './agent-status-mirror' + +const questions = [ + { question: 'Which languages?', header: 'Languages', multiSelect: true, options: [{ label: 'Kotlin', description: 'Phone' }, { label: 'TypeScript', description: 'Host' }] }, + { question: 'Where?', header: 'Place', multiSelect: false, options: [{ label: 'Here', description: '' }, { label: 'There', description: '' }] } +] +function hook(extra: Record) { + const payload = { hook_event_name: 'PreToolUse', session_id: 'parent', tool_name: 'AskUserQuestion', tool_use_id: 'tool-1', tool_input: { questions }, ...extra } + recordRawToolEvent('node', payload) + const event = normalizeClaude({ nodeId: 'node', agentId: 'claude', payload }) + if (event) recordAgentEvent({ ...event, verified: true }) +} +beforeEach(() => { _resetForTest(); vi.useFakeTimers(); vi.setSystemTime(100000) }) +afterEach(() => { _resetForTest(); vi.useRealTimers() }) +describe('held hook reply producer compatibility', () => { + it('publishes full held questions while an old Inbox or push consumer receives no numbered actions', () => { + const live: NodeStateChange[] = [] + onNodeStateChange(e => live.push(e)) + hook({ nodeterm_hook_reply: 2, nodeterm_pending_id: 'node-100000-42' }) + const card = _inboxSnapshot().events[0] + expect(card).toMatchObject({ kind: 'question', questionPendingId: 'node-100000-42', questions }) + expect(card.options).toBeUndefined() + expect(card.multiSelect).toBeUndefined() + expect(card.pendingId).toBeUndefined() + expect(live.find(e => e.state === 'needsYou')).toMatchObject({ kind: 'question' }) + expect(live.find(e => e.state === 'needsYou')?.options).toBeUndefined() + expect(hookTicketStillOpen('node', 'node-100000-42', 'question')).toBe(true) + expect(hookTicketStillOpen('other', 'node-100000-42', 'question')).toBe(false) + expect(hookTicketStillOpen('node', 'node-100000-42', 'approval')).toBe(false) + }) + it('preserves legacy unheld numbered choices and read-only multi-select indication', () => { + hook({}) + expect(_inboxSnapshot().events[0]).toMatchObject({ options: ['Kotlin', 'TypeScript'], multiSelect: true }) + expect(_inboxSnapshot().events[0].questionPendingId).toBeUndefined() + }) + it('does not expose legacy digit actions for a held schema it cannot answer', () => { + hook({ nodeterm_hook_reply: 2, nodeterm_pending_id: 'node-100000-42', tool_input: { questions: [{ ...questions[0], header: undefined }] } }) + const card = _inboxSnapshot().events[0] + expect(card.questionPendingId).toBe('node-100000-42') + expect(card.questions).toBeUndefined() + expect(card.options).toBeUndefined() + expect(hookTicketStillOpen('node', 'node-100000-42', 'question')).toBe(false) + }) + it('correlates the hook answer with its actual question and rejects a settled ticket', () => { + hook({ nodeterm_hook_reply: 2, nodeterm_pending_id: 'node-100000-42' }) + hook({ nodeterm_answered: 'allow', nodeterm_pending_id: 'node-100000-42' }) + expect(_snapshot().node.state).toBe('working') + expect(_inboxSnapshot().events[0].resolved).toBe(true) + expect(hookTicketStillOpen('node', 'node-100000-42', 'question')).toBe(false) + }) + it('gives identical text on a new held ticket a distinct card instead of reusing the stale request', () => { + hook({ nodeterm_hook_reply: 2, nodeterm_pending_id: 'node-100000-42' }) + hook({ nodeterm_answered: 'allow', nodeterm_pending_id: 'node-100000-42' }) + hook({ nodeterm_hook_reply: 2, nodeterm_pending_id: 'node-100001-43', tool_use_id: 'tool-2' }) + expect(_inboxSnapshot().events).toHaveLength(2) + expect(_inboxSnapshot().events.filter(e => !e.resolved).map(e => e.questionPendingId)).toEqual(['node-100001-43']) + }) + it('offers only host-derived concrete approval rules while child approvals remain independently answerable', () => { + hook({ nodeterm_hook_reply: 2, nodeterm_pending_id: 'node-100000-42' }) + hook({ hook_event_name: 'PermissionRequest', tool_name: 'Bash', agent_id: 'child', nodeterm_hook_reply: 2, nodeterm_pending_id: 'node-100001-43', + permission_suggestions: [{ type: 'addRules', destination: 'session', behavior: 'allow', rules: [{ toolName: 'Bash', ruleContent: 'npm test' }] }] }) + const approval = _inboxSnapshot().events.find(e => e.kind === 'approval')! + expect(approval.permissionSuggestions).toEqual([{ index: 0, label: 'Bash(npm test) — session' }]) + expect(hookTicketStillOpen('node', 'node-100001-43', 'approval')).toBe(true) + expect(hookTicketStillOpen('node', 'node-100000-42', 'question')).toBe(true) + }) +}) diff --git a/src/core/history-scroll-view.test.ts b/src/core/history-scroll-view.test.ts new file mode 100644 index 0000000000..c7b328c34a --- /dev/null +++ b/src/core/history-scroll-view.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HistoryScrollBudget, HistoryScrollView, HISTORY_VIEW_TTL_MS } from './history-scroll-view' +import { HISTORY_CAPTURE_MAX_BYTES, HISTORY_PAGE_MAX_BYTES, type HistoryScrollCapture } from '../shared/history-scroll' + +const views: HistoryScrollView[] = [] +const capture = (count = 50, viewportRows = 4): HistoryScrollCapture => ({ cols: 80, viewportRows, + olderTruncated: false, rows: Array.from({ length: count }, (_, i) => ({ text: `row ${i}`, isWrapped: i === 2, + section: i >= count - 2 ? 'alternate' : 'normal' })) }) +function view(budget = new HistoryScrollBudget()) { const v = new HistoryScrollView(budget); views.push(v); return v } +afterEach(() => { views.splice(0).forEach(v => v.clear()); vi.useRealTimers() }) + +describe('one viewer-owned native history snapshot', () => { + it('pages physical rows in both directions without mutating input or sharing another viewer position', () => { + const a = view(), b = view(), source = capture(); a.install(source); b.install(source) + const first = a.page(true, 2) + expect(first.offset).toBe(6); expect(first.rows.map(r => r.text)).toEqual(['row 40', 'row 41', 'row 42', 'row 43']) + expect(first.hasOlder).toBe(true); expect(first.hasNewer).toBe(true) + expect(a.needsCapture(first.viewId)).toBe(false) + expect(b.page(true, 1).offset).toBe(3) + const down = a.page(false, 20) + expect(down.offset).toBe(0); expect(down.hasNewer).toBe(false) + expect(down.rows.at(-1)?.section).toBe('alternate') + source.rows[40].text = 'new output'; first.rows[1].text = 'caller changed page' + expect(a.page(true, 2).rows.map(r => r.text)).toEqual(['row 40', 'row 41', 'row 42', 'row 43']) + }) + it('keeps wraps, alternate boundaries and honest truncation at the oldest retained page', () => { + const v = view(), source = capture(8); source.olderTruncated = true; v.install(source) + const page = v.page(true, 20) + expect(page.offset).toBe(4); expect(page.hasOlder).toBe(false); expect(page.olderTruncated).toBe(true) + expect(page.rows[2]).toEqual({ text: 'row 2', isWrapped: true, section: 'normal' }) + expect(v.page(false, 1).rows.at(-1)?.section).toBe('alternate') + }) + it('refuses a foreign/expired id and reclaims the shared budget on clear, replacement and TTL', () => { + vi.useFakeTimers(); const budget = new HistoryScrollBudget(1200), a = view(budget), b = view(budget) + a.install(capture(10)); const used = budget.usedBytes, id = a.page(true, 1).viewId + expect(() => b.needsCapture(id)).toThrow('expired or changed') + expect(() => b.install(capture(10))).toThrow('busy'); expect(budget.usedBytes).toBe(used) + a.install(capture(2)); expect(budget.usedBytes).toBeLessThan(used) + expect(() => a.needsCapture(id)).toThrow('expired or changed') + a.clear(); a.clear(); expect(budget.usedBytes).toBe(0) + b.install(capture(10)); const newId = b.page(true, 1).viewId + vi.advanceTimersByTime(HISTORY_VIEW_TTL_MS) + expect(budget.usedBytes).toBe(0); expect(() => b.needsCapture(newId)).toThrow('expired or changed') + }) + it('bounds encoded snapshot/page bytes and refuses malformed capture/gestures without changing position', () => { + const v = view() + for (const bad of [{ ...capture(), cols: 0 }, { ...capture(), viewportRows: 201 }, { ...capture(), rows: capture(8193).rows }, { ...capture(), rows: [] }, + { ...capture(), rows: [{ text: 'x', isWrapped: false, section: 'foreign' }] }]) + expect(() => v.install(bad as HistoryScrollCapture)).toThrow('invalid') + expect(() => v.install({ ...capture(), rows: [{ text: 'Ω'.repeat(HISTORY_CAPTURE_MAX_BYTES), isWrapped: false, section: 'normal' }] })).toThrow('too much') + v.install(capture()); const initial = v.page(false, 1) + for (const n of [0, -1, 21, 1.5, Number.NaN]) expect(() => v.page(true, n)).toThrow('Invalid') + expect(v.page(false, 1).offset).toBe(initial.offset) + v.install({ cols: 65535, viewportRows: 1, olderTruncated: false, rows: [{ text: 'Ω'.repeat(HISTORY_PAGE_MAX_BYTES / 2), isWrapped: false, section: 'normal' }] }) + expect(() => v.page(true, 1)).toThrow('too large') + }) +}) diff --git a/src/core/history-scroll-view.ts b/src/core/history-scroll-view.ts new file mode 100644 index 0000000000..3daf1120e1 --- /dev/null +++ b/src/core/history-scroll-view.ts @@ -0,0 +1,108 @@ +import { randomUUID } from 'node:crypto' +import { performance } from 'node:perf_hooks' +import { + HISTORY_CAPTURE_MAX_BYTES, HISTORY_CAPTURE_MAX_ROWS, HISTORY_PAGE_MAX_BYTES, + HISTORY_PAGE_MAX_ROWS, HISTORY_ROWS_PER_NOTCH, + type HistoryScrollCapture, type HistoryScrollPage, type HistoryScrollRow +} from '../shared/history-scroll' + +export const HISTORY_VIEW_TTL_MS = 60_000 +export const HISTORY_VIEW_GLOBAL_BYTES = 16 * 1024 * 1024 + +/** All relay peers share this retained snapshot budget; one peer cannot allocate per-host caps. */ +export class HistoryScrollBudget { + private used = 0 + constructor(private readonly limit = HISTORY_VIEW_GLOBAL_BYTES) {} + get usedBytes(): number { return this.used } + reserve(bytes: number): boolean { + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.limit - this.used) return false + this.used += bytes + return true + } + release(bytes: number): void { this.used -= bytes } +} +const sharedBudget = new HistoryScrollBudget() + +interface HeldView { + id: string + capture: HistoryScrollCapture + bytes: number + offset: number + expiresAt: number +} + +/** Owned by one exact attached stream. It never moves or writes the application's terminal. */ +export class HistoryScrollView { + private held: HeldView | null = null + private timer: ReturnType | null = null + constructor(private readonly budget = sharedBudget, private readonly now = () => performance.now()) {} + + /** Missing id starts a new intent; an expired/foreign id cannot silently become another view. */ + needsCapture(viewId?: string): boolean { + if (!viewId) return true + if (!this.held || this.held.id !== viewId || this.held.expiresAt <= this.now()) { + if (this.held?.expiresAt !== undefined && this.held.expiresAt <= this.now()) this.clear() + throw new Error('This history view expired or changed. Swipe again to open history.') + } + return false + } + + install(value: HistoryScrollCapture): void { + if (!value || !Number.isInteger(value.cols) || value.cols < 1 || value.cols > 65535 || + !Number.isInteger(value.viewportRows) || value.viewportRows < 1 || value.viewportRows > HISTORY_PAGE_MAX_ROWS || + typeof value.olderTruncated !== 'boolean' || !Array.isArray(value.rows) || value.rows.length < 1 || value.rows.length > HISTORY_CAPTURE_MAX_ROWS) + throw new Error('The computer returned invalid terminal history.') + const rows: HistoryScrollRow[] = [] + for (const row of value.rows) { + if (!row || typeof row.text !== 'string' || typeof row.isWrapped !== 'boolean' || + (row.section !== 'normal' && row.section !== 'alternate')) + throw new Error('The computer returned invalid terminal history.') + rows.push({ text: row.text, isWrapped: row.isWrapped, section: row.section }) + } + const capture = { cols: value.cols, viewportRows: value.viewportRows, olderTruncated: value.olderTruncated, rows } + const bytes = Buffer.byteLength(JSON.stringify(capture), 'utf8') + if (bytes > HISTORY_CAPTURE_MAX_BYTES) throw new Error('The computer returned too much terminal history.') + this.clear() + if (!this.budget.reserve(bytes)) throw new Error('Terminal history is busy. Close another history view and try again.') + this.held = { id: randomUUID(), capture, bytes, offset: 0, expiresAt: 0 } + this.renew() + } + + page(up: boolean, notches: number): HistoryScrollPage { + if (!Number.isInteger(notches) || notches < 1 || notches > 20) throw new Error('Invalid history movement.') + const held = this.held + if (!held || held.expiresAt <= this.now()) { this.clear(); throw new Error('This history view expired. Swipe again to open history.') } + const { capture } = held + const totalRows = capture.rows.length + const maxOffset = Math.max(0, totalRows - capture.viewportRows) + const offset = Math.max(0, Math.min(maxOffset, held.offset + (up ? 1 : -1) * notches * HISTORY_ROWS_PER_NOTCH)) + const end = totalRows - offset + const start = Math.max(0, end - capture.viewportRows) + const result: HistoryScrollPage = { + status: 'history', viewId: held.id, offset, totalRows, cols: capture.cols, + rows: capture.rows.slice(start, end).map(row => ({ ...row })), + olderTruncated: capture.olderTruncated, hasOlder: offset < maxOffset, hasNewer: offset > 0 + } + if (Buffer.byteLength(JSON.stringify(result), 'utf8') > HISTORY_PAGE_MAX_BYTES) + throw new Error('This history page is too large. Use a smaller terminal view.') + held.offset = offset + this.renew() + return result + } + + clear(): void { + if (this.timer) clearTimeout(this.timer) + this.timer = null + if (this.held) this.budget.release(this.held.bytes) + this.held = null + } + + private renew(): void { + const held = this.held + if (!held) return + held.expiresAt = this.now() + HISTORY_VIEW_TTL_MS + if (this.timer) clearTimeout(this.timer) + this.timer = setTimeout(() => { if (this.held === held) this.clear() }, HISTORY_VIEW_TTL_MS) + this.timer.unref?.() + } +} diff --git a/src/core/managed-pane.realtmux.test.ts b/src/core/managed-pane.realtmux.test.ts new file mode 100644 index 0000000000..e6df4f0282 --- /dev/null +++ b/src/core/managed-pane.realtmux.test.ts @@ -0,0 +1,78 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' +import { managedCreateFlags, managedLaunchPlan, MANAGED_PANE_FORMAT, parseManagedPane, readManagedProcessBirth } from './managed-pane' +import type { ManagedPaneReceipt } from '../shared/managed-terminal' +import { TMUX_SOCKET } from './tmux-naming' +import { makeTmuxTmpdir } from './tmux-test-socket' + +const supported = process.platform === 'linux' || process.platform === 'darwin' +const tmux = (() => { try { execFileSync('tmux', ['-V'], { stdio: 'ignore' }); return 'tmux' } catch { return null } })() +let work: string +const creationId = '1e8dd6ac-050b-4e63-a331-9f6e9df8b7fb' +const nodeId = `term-${Date.now().toString(36)}-${process.pid.toString(36)}` +const session = 'nt-' + nodeId +function run(args: string[], input?: string): string { + return execFileSync(tmux!, args, { encoding: 'utf8', input, timeout: 3000, + env: { ...process.env, TMUX_TMPDIR: work, TMUX: '', TMUX_PANE: '', LC_ALL: 'C' } }) +} +function command(args: string[]): string { return run(['-L', TMUX_SOCKET, ...args]) } +async function waitFor(file: string): Promise { + const deadline = Date.now() + 2000 + while (!fs.existsSync(file) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10)) + expect(fs.existsSync(file)).toBe(true) +} +async function identity(): Promise { + const fields = parseManagedPane(command(['list-panes', '-s', '-t', '=' + session, '-F', MANAGED_PANE_FORMAT]), { session, creationId }) + expect(fields).not.toBeNull() + return { version: 1, creationId, nodeId, projectId: 'fixture-project', socket: TMUX_SOCKET, + session, ...fields!, paneBirth: await readManagedProcessBirth(fields!.panePid) } +} +describe.skipIf(!supported || !tmux)('managed creation against isolated real tmux', () => { + beforeAll(() => { + // The schema contains the REAL socket name. Its own TMUX_TMPDIR makes this suite independent + // even from the run's other tmux fixtures; cleanup addresses ONLY the owned exact session. + work = makeTmuxTmpdir('nt-managed-', TMUX_SOCKET) + const folder = path.join(work, "folder '$(touch injected) with spaces") + fs.mkdirSync(folder) + command(['-f', '/dev/null', 'new-session', '-d', ...managedCreateFlags(creationId), '-c', folder, + '-s', session, 'bash', '--noprofile', '--norc']) + }) + afterAll(() => { + if (!work) return + try { command(['kill-session', '-t', '=' + session]) } catch { /* may have exited */ } + fs.rmSync(work, { recursive: true, force: true }) + }) + it('refuses a warm-name collision without changing the pane or cwd', async () => { + const original = await identity() + expect(() => command(['new-session', '-d', ...managedCreateFlags('f56b943e-de49-4f84-8a09-96e6890b64c7'), '-s', session, 'sleep', '10'])).toThrow() + expect(await identity()).toEqual(original) + expect(command(['display-message', '-p', '-t', original.paneId, '#{pane_current_path}']).trim()).toContain("folder '$(touch injected) with spaces") + expect(fs.existsSync(path.join(work, 'injected'))).toBe(false) + }) + it('delivers exactly to the attested pane, leaves copy mode, and refuses a changed receipt', async () => { + const r = await identity() + const marker = path.join(work, 'delivered') + command(['copy-mode', '-t', r.paneId]) + const plan = managedLaunchPlan(r, `printf managed > '${marker}'`) + expect(run(plan.args, plan.body).trim()).toBe('nt-managed-delivered') + await waitFor(marker) + expect(fs.readFileSync(marker, 'utf8')).toBe('managed') + expect(command(['display-message', '-p', '-t', r.paneId, '#{pane_in_mode}']).trim()).toBe('0') + for (const replacement of [ + { ...r, creationId: 'f56b943e-de49-4f84-8a09-96e6890b64c7' }, + { ...r, panePid: r.panePid + 1 }, { ...r, sessionCreated: String(Number(r.sessionCreated) + 1) }, + { ...r, session: r.session + 'a' } + ]) { + const unsafe = managedLaunchPlan(replacement, `printf unsafe >> '${marker}'`) + expect(run(unsafe.args, unsafe.body).trim()).toBe('nt-managed-refused') + } + expect(fs.readFileSync(marker, 'utf8')).toBe('managed') + }) + it('does not regard a second window/pane as the original one-pane receipt', async () => { + command(['new-window', '-d', '-t', '=' + session, 'sleep', '30']) + const raw = command(['list-panes', '-s', '-t', '=' + session, '-F', MANAGED_PANE_FORMAT]) + expect(parseManagedPane(raw, { session, creationId })).toBeNull() + }) +}) diff --git a/src/core/managed-pane.test.ts b/src/core/managed-pane.test.ts new file mode 100644 index 0000000000..4b1a505d27 --- /dev/null +++ b/src/core/managed-pane.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { linuxManagedBirth, managedCreateFlags, managedLaunchPlan, parseManagedPane, sameManagedPane } from './managed-pane' +import type { ManagedPaneReceipt } from '../shared/managed-terminal' + +const receipt: ManagedPaneReceipt = { + version: 1, creationId: '1e8dd6ac-050b-4e63-a331-9f6e9df8b7fb', nodeId: 'term-test-1234', projectId: 'local-project', + socket: 'node-terminal', session: 'nt-term-test-1234', paneId: '%42', panePid: 1234, + paneBirth: 'linux:12345678-1234-1234-1234-123456789012:123', sessionCreated: '1770000000' +} + +describe('managed pane receipt', () => { + it('requires exactly one whole-session pane matching its creation and exact session', () => { + const raw = `${receipt.session}|${receipt.sessionCreated}|${receipt.paneId}|${receipt.panePid}|${receipt.creationId}` + expect(parseManagedPane(raw + '\n', receipt)).toEqual({ sessionCreated: receipt.sessionCreated, paneId: receipt.paneId, panePid: receipt.panePid }) + for (const changed of [raw + '\n' + raw, raw.replace('nt-term-test-1234', 'nt-term-test-12345'), raw.replace(receipt.creationId, 'other'), + raw.replace('|%42|', '|x|'), raw.replace('|1234|', '|0|'), raw.replace('|1234|', '|9007199254740993|'), raw + '|extra']) + expect(parseManagedPane(changed, receipt)).toBeNull() + }) + it('compares every generation/ownership field without coercion', () => { + expect(sameManagedPane(receipt, { ...receipt })).toBe(true) + for (const key of Object.keys(receipt)) { + const changed = { ...receipt, [key]: typeof receipt[key as keyof ManagedPaneReceipt] === 'number' ? 999 : 'other' } + expect(sameManagedPane(receipt, changed as ManagedPaneReceipt), key).toBe(false) + } + }) + it('reads Linux field22 after hostile comm parentheses and binds the boot', () => { + const stat = '1234 (name ) with spaces)) S ' + Array.from({ length: 18 }, () => '0').join(' ') + ' 987 0' + expect(linuxManagedBirth(stat, '12345678-1234-1234-1234-123456789012\n')).toBe('linux:12345678-1234-1234-1234-123456789012:987') + expect(linuxManagedBirth('bad', '12345678-1234-1234-1234-123456789012')).toBeNull() + expect(linuxManagedBirth(stat, 'invalid')).toBeNull() + }) + it('creates without attach-or-create or detach flags and passes only a validated marker', () => { + expect(managedCreateFlags(receipt.creationId)).toEqual(['-e', 'NODETERM_MANAGED_CREATION_ID=' + receipt.creationId]) + expect(() => managedCreateFlags('$(touch x)')).toThrow() + }) + it('keeps launch text on stdin and gates exact pane before both paste and Enter', () => { + const plan = managedLaunchPlan(receipt, 'claude --permission-mode default') + expect(plan.body).toBe('claude --permission-mode default') + expect(plan.args.join(' ')).not.toContain(plan.body) + expect(plan.args).toContain('%42') + const condition = plan.args[plan.args.indexOf('if-shell') + 4] + for (const fact of [receipt.creationId, receipt.session, receipt.sessionCreated, String(receipt.panePid)]) expect(condition).toContain(fact) + expect(plan.args.at(-2)).toContain('paste-buffer -d -p -r') + expect(plan.args.at(-2)).toContain('send-keys -t %42 Enter') + expect(plan.args.at(-1)).toContain('nt-managed-refused') + for (const command of ['', 'bad\r', 'bad\n', '\u001b', 'a'.repeat(32769)]) expect(() => managedLaunchPlan(receipt, command)).toThrow() + expect(() => managedLaunchPlan({ ...receipt, paneId: '%42; kill-server' }, 'claude')).toThrow() + }) +}) diff --git a/src/core/managed-pane.ts b/src/core/managed-pane.ts new file mode 100644 index 0000000000..9fb228c5e4 --- /dev/null +++ b/src/core/managed-pane.ts @@ -0,0 +1,87 @@ +import fs from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import type { ManagedPaneReceipt } from '../shared/managed-terminal' +import { MANAGED_CREATION_ENV, MANAGED_CREATION_UUID } from '../shared/managed-terminal' +import { pasteBufferName, TMUX_SOCKET } from './tmux-naming' + +export const MANAGED_PANE_FORMAT = '#{session_name}|#{session_created}|#{pane_id}|#{pane_pid}|#{' + MANAGED_CREATION_ENV + '}' + +/** Require one pane in the whole session; a selected window is insufficient. */ +export function parseManagedPane(raw: string, expected: Pick): + Pick | null { + const rows = raw.trim().split('\n') + if (rows.length !== 1) return null + const [session, created, pane, pid, creation, ...extra] = rows[0].split('|') + if (extra.length || session !== expected.session || creation !== expected.creationId || + !/^[1-9][0-9]*$/.test(created) || !/^%[0-9]+$/.test(pane) || !/^[1-9][0-9]*$/.test(pid)) return null + const panePid = Number(pid) + if (!Number.isSafeInteger(panePid)) return null + return { sessionCreated: created, paneId: pane, panePid } +} + +/** /proc comm may itself contain ')' or spaces. Field 22 follows the FINAL closing parenthesis. */ +export function linuxManagedBirth(stat: string, boot: string): string | null { + const close = stat.lastIndexOf(')') + const ticks = close >= 0 ? stat.slice(close + 1).trim().split(/\s+/)[19] : undefined + if (!ticks || !/^[0-9]+$/.test(ticks) || !/^[0-9a-f-]{36}$/.test(boot.trim())) return null + return `linux:${boot.trim()}:${ticks}` +} + +export async function readManagedProcessBirth(pid: number): Promise { + if (!Number.isSafeInteger(pid) || pid < 1) throw new Error('Invalid managed pane process') + if (process.platform === 'linux') { + const [stat, boot] = await Promise.all([ + fs.readFile(`/proc/${pid}/stat`, 'utf8'), fs.readFile('/proc/sys/kernel/random/boot_id', 'utf8') + ]) + const birth = linuxManagedBirth(stat, boot) + if (!birth) throw new Error('Could not attest the managed pane process') + return birth + } + if (process.platform === 'darwin') { + const { stdout } = await promisify(execFile)('ps', ['-o', 'lstart=', '-p', String(pid)], { timeout: 1500, env: { ...process.env, LC_ALL: 'C' } }) + const value = stdout.trim().replace(/\s+/g, ' ') + if (!/^[A-Za-z]{3} [A-Za-z]{3} [0-9]{1,2} [0-9]{2}:[0-9]{2}:[0-9]{2} [0-9]{4}$/.test(value)) + throw new Error('Could not attest the managed pane process') + return `darwin:${value}` + } + throw new Error('Managed SSH creation requires Linux or macOS tmux') +} + +export function sameManagedPane(a: ManagedPaneReceipt, b: ManagedPaneReceipt): boolean { + return a.version === b.version && a.creationId === b.creationId && a.nodeId === b.nodeId && + a.projectId === b.projectId && a.socket === b.socket && a.session === b.session && + a.paneId === b.paneId && a.panePid === b.panePid && a.paneBirth === b.paneBirth && + a.sessionCreated === b.sessionCreated +} + +/** The create-only flag is passed privately through normal spawn policy, never as a wire option. */ +export function managedCreateFlags(creationId: string): string[] { + if (!MANAGED_CREATION_UUID.test(creationId)) throw new Error('Invalid managed creation id') + return ['-e', `${MANAGED_CREATION_ENV}=${creationId}`] +} + +/** A single tmux queue owns the final identity gate, paste and Enter. Text travels on stdin. + * The receipt's process birth is separately re-read by the host immediately before this dispatch; + * pane IDs are immutable for this tmux server, and the queue checks PID, creation and session too. */ +export function managedLaunchPlan(r: ManagedPaneReceipt, command: string): { args: string[]; body: string; cleanup: string[] } { + if (r.socket !== TMUX_SOCKET || !MANAGED_CREATION_UUID.test(r.creationId) || + !/^nt-term-[a-z0-9]+-[a-z0-9]{1,16}$/.test(r.session) || !/^%[0-9]+$/.test(r.paneId) || + !Number.isSafeInteger(r.panePid) || r.panePid < 1 || !/^[1-9][0-9]*$/.test(r.sessionCreated) || + !command || command.length > 32_768 || /[\r\n\u0000\u001b]/.test(command)) + throw new Error('Invalid managed launch') + const buffer = pasteBufferName() + const parts = [ + `#{==:#{session_name},${r.session}}`, `#{==:#{session_created},${r.sessionCreated}}`, + `#{==:#{pane_pid},${r.panePid}}`, `#{==:#{${MANAGED_CREATION_ENV}},${r.creationId}}` + ] + const condition = parts.reduce((a, b) => `#{&&:${a},${b}}`) + const yes = `if-shell -F -t ${r.paneId} '#{pane_in_mode}' 'send-keys -t ${r.paneId} -X cancel' ; ` + + `paste-buffer -d -p -r -b ${buffer} -t ${r.paneId} ; send-keys -t ${r.paneId} Enter ; display-message -p nt-managed-delivered` + const no = `delete-buffer -b ${buffer} ; display-message -p nt-managed-refused` + return { + args: ['-L', r.socket, 'load-buffer', '-b', buffer, '-', ';', 'if-shell', '-F', '-t', r.paneId, condition, yes, no], + body: command, + cleanup: ['-L', r.socket, 'delete-buffer', '-b', buffer] + } +} diff --git a/src/core/managed-terminal-plan.test.ts b/src/core/managed-terminal-plan.test.ts new file mode 100644 index 0000000000..c5ba6e5619 --- /dev/null +++ b/src/core/managed-terminal-plan.test.ts @@ -0,0 +1,315 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_SETTINGS, UNKNOWN_CLAUDE_CLI_CAPS, UNKNOWN_CODEX_CLI_CAPS, UNKNOWN_GROK_CLI_CAPS, + type Project, type Settings, type Workspace } from '../shared/types' +import type { ManagedTerminalRequest } from './managed-terminals' +import { createManagedTerminalPlanner, type ManagedTerminalPlannerDeps } from './managed-terminal-plan' +import { findInLoginPath } from './exec-path' + +vi.mock('./codex-accounts-core', async (original) => ({ + ...(await original()), + codexAccountHome: (root: string, id: string) => path.join(root, 'codex-test', id) +})) +vi.mock('node-pty', () => ({ spawn: vi.fn(() => { throw new Error('planner must not spawn a PTY') }) })) + +describe('host-owned managed terminal planner', () => { + let root: string + let project: Project + let settings: Settings + let workspace: Workspace + let snapshot: Awaited> + let deps: ManagedTerminalPlannerDeps + let request: ManagedTerminalRequest + const nodeId = 'term-managed-owned' + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'nodeterm-managed-plan-')) + const cwd = path.join(root, 'repo') + await fs.mkdir(cwd) + project = { id: 'owned-project', name: 'Repo', color: '#123456', cwd, + nodes: [], viewport: { x: 0, y: 0, zoom: 1 } } + workspace = { projects: [project], activeProjectId: project.id } as Workspace + settings = structuredClone(DEFAULT_SETTINGS) + snapshot = { shared: null, local: undefined } + deps = { + userData: root, settings: () => settings, + store: { load: vi.fn(async () => workspace), + projectTargetInfo: vi.fn(() => ({ cwd: project.cwd, ssh: project.ssh, name: project.name })), + readProjectSettings: vi.fn(async () => snapshot) }, + trust: { isTrusted: vi.fn(async () => false) }, + executable: vi.fn(async (bin) => bin.startsWith('/') ? bin : '/bin/' + bin), + sessionShell: (program, fallback) => program || fallback || '/bin/bash', + claudeCaps: vi.fn(async () => ({ ...UNKNOWN_CLAUDE_CLI_CAPS, autoPermissionMode: true, sessionIdFlag: true })), + codexCaps: vi.fn(async () => ({ ...UNKNOWN_CODEX_CLI_CAPS, approvalValues: ['on-request', 'never'] })), + grokCaps: vi.fn(async () => ({ ...UNKNOWN_GROK_CLI_CAPS, sessionIdFlag: true })), + codexIdentity: vi.fn(async () => ({ shared: false, launcherPath: null })), + env: { WRAPPER: 'fixture' } + } + request = { creationId: '11111111-1111-4111-8111-111111111111', projectId: project.id, + kind: 'shell', cols: 80, rows: 24 } + }) + afterEach(async () => { vi.unstubAllEnvs(); await fs.rm(root, { recursive: true, force: true }) }) + const planner = () => createManagedTerminalPlanner(deps) + const agent = (agentId: 'claude' | 'codex' | 'grok' | 'gemini' | 'opencode' | 'copilot' = 'claude') => + ({ ...request, kind: 'agent' as const, agentId }) + + it('creates shell facts from the genuine local owner and never supplies an agent command', async () => { + const plan = await planner().plan(request, nodeId) + expect(plan.node).toMatchObject({ id: nodeId, kind: 'terminal', title: 'Terminal', cwd: project.cwd, shell: '/bin/bash' }) + expect(plan.options).toEqual({ persistKey: nodeId, ownerProjectId: project.id, cwd: project.cwd, + shell: '/bin/bash', cols: 80, rows: 24 }) + expect(plan.command).toBeUndefined() + expect(plan.node.agentId).toBeUndefined() + expect(await planner().revalidate(request, plan)).toBe(true) + const largest = await planner().plan({ ...request, cols: 500, rows: 500, title: 'a'.repeat(120) }, nodeId) + expect(largest.options).toMatchObject({ cols: 500, rows: 500 }) + expect(largest.node.title).toHaveLength(120) + }) + + it.skipIf(process.platform === 'win32')('plans the inherited absolute POSIX shell using the real executable resolver', async () => { + // The physical Android New Terminal path uses these same production dependencies. With a + // blank defaultShell, the inherited absolute SHELL used to be looked up beneath each PATH dir. + const { resolveLocalSessionShell } = await import('./pty-manager') + const shell = path.join(root, 'qa-shell') + await fs.writeFile(shell, '#!/bin/sh\nexit 0\n', { mode: 0o755 }) + vi.stubEnv('SHELL', shell) + vi.stubEnv('PATH', path.join(root, 'unrelated-path')) + settings.defaultShell = '' + deps.executable = findInLoginPath + deps.sessionShell = resolveLocalSessionShell + const plan = await planner().plan(request, nodeId) + expect(plan.node.shell).toBe(shell) + expect(plan.options.shell).toBe(shell) + expect(plan.command).toBeUndefined() + expect(await planner().revalidate(request, plan)).toBe(true) + await fs.unlink(shell) + expect(await planner().revalidate(request, plan)).toBe(false) + await expect(planner().plan(request, nodeId)).rejects.toThrow('The configured shell is unavailable.') + }) + + it('rejects extra executable hints, unsupported custom agents, account-on-shell and invalid sizes before resolution', async () => { + for (const patch of [{ cwd: '/wrong' }, { command: 'echo bad' }, { shell: 'sh' }, { kind: 'agent', agentId: 'custom:evil' }, + { accountId: 'account' }, { cols: 0 }, { rows: 501 }, { creationId: '../bad' }, + { projectId: 'a'.repeat(201) }, { title: 'a'.repeat(121) }, { title: 'bad\nline' }]) { + await expect(planner().plan({ ...request, ...patch } as ManagedTerminalRequest, nodeId)).rejects.toThrow() + } + expect(deps.store.load).not.toHaveBeenCalled() + }) + + it('rejects missing, closed, SSH, driven, inline and ambiguous project ownership', async () => { + const original = structuredClone(project) + for (const patch of [{ closed: true }, { ssh: { server: { host: 'other', user: 'user' }, remoteCwd: '/remote' } }, + { drivenRemotely: true }, { cwd: undefined }]) { + Object.assign(project, original, patch) + await expect(planner().plan(request, nodeId)).rejects.toThrow('open local folder') + delete (project as unknown as { drivenRemotely?: boolean }).drivenRemotely + delete project.ssh + delete project.closed + } + Object.assign(project, original) + workspace.projects = [] + await expect(planner().plan(request, nodeId)).rejects.toThrow('unavailable or ambiguous') + workspace.projects = [project, structuredClone(project)] + await expect(planner().plan(request, nodeId)).rejects.toThrow('unavailable or ambiguous') + }) + + it('requires the current actual folder and rejects settings conflicts', async () => { + await fs.rm(project.cwd!, { recursive: true }) + await expect(planner().plan(request, nodeId)).rejects.toThrow() + await fs.mkdir(project.cwd!) + snapshot = { shared: null, local: undefined, conflict: true } + await expect(planner().plan(request, nodeId)).rejects.toThrow('settings conflict') + }) + + it('uses each agent’s own approval dialect and stable supported provider session identity', async () => { + settings.claudePermissionMode = 'auto' + const claude = await planner().plan(agent(), nodeId) + expect(claude.command).toContain('--permission-mode auto') + expect(claude.node.agentSessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + expect(claude.node.agentSessionId).not.toBe(request.creationId) + expect(claude.command).toContain('--session-id ' + claude.node.agentSessionId) + expect((await planner().plan(agent(), nodeId)).node.agentSessionId).toBe(claude.node.agentSessionId) + deps.claudeCaps = async () => UNKNOWN_CLAUDE_CLI_CAPS + const oldClaude = await planner().plan(agent(), nodeId) + expect(oldClaude.command).not.toContain('--permission-mode auto') + expect(oldClaude.command).not.toContain('--session-id') + settings.claudePermissionMode = 'auto' + const codex = await planner().plan(agent('codex'), nodeId) + expect(codex.command).toContain('--ask-for-approval on-request') + expect(codex.command).not.toContain('untrusted') + const grok = await planner().plan(agent('grok'), nodeId) + expect(grok.command).toContain('--session-id ' + grok.node.agentSessionId) + expect(grok.node.agentSessionId).not.toBe(claude.node.agentSessionId) + for (const id of ['gemini', 'opencode', 'copilot'] as const) { + expect((await planner().plan(agent(id), nodeId)).node.agentId).toBe(id) + } + }) + + it('takes project permission policy before global policy and ignores phone-supplied model or mode', async () => { + project.defaultPermissionMode = 'plan' + settings.claudePermissionMode = 'bypassPermissions' + expect((await planner().plan(agent(), nodeId)).command).toContain('--permission-mode plan') + for (const patch of [{ model: 'injected' }, { permissionMode: 'bypassPermissions' }]) { + await expect(planner().plan({ ...agent(), ...patch } as ManagedTerminalRequest, nodeId)).rejects.toThrow('Invalid') + } + }) + + it('honors paired local/project-trusted launch overrides and rejects shared environment expansion', async () => { + settings.agentLaunchCommands = { claude: 'global-wrapper claude' } + snapshot = { shared: { version: 1, rev: 1, savedAt: '', agents: { defaultAgentId: 'claude', launchCmd: 'shared-wrapper claude' } }, local: undefined } + expect((await planner().plan(agent(), nodeId)).command).toMatch(/^global-wrapper claude/) + deps.trust.isTrusted = async () => true + expect((await planner().plan(agent(), nodeId)).command).toMatch(/^shared-wrapper claude/) + snapshot.local = { agents: { defaultAgentId: 'claude', launchCmd: 'local-wrapper claude' } } + deps.trust.isTrusted = async () => false + expect((await planner().plan(agent(), nodeId)).command).toMatch(/^local-wrapper claude/) + snapshot.local.agents!.launchCmd = '${env:WRAPPER} claude' + expect((await planner().plan(agent(), nodeId)).command).toMatch(/^global-wrapper claude/) + snapshot.local.agents!.defaultAgentId = 'codex' + snapshot.local.agents!.launchCmd = 'wrong-agent-wrapper' + expect((await planner().plan(agent(), nodeId)).command).toMatch(/^global-wrapper claude/) + }) + + it('refuses missing real CLI, missing wrapper, unsupported shell and missing global expansion instead of defaulting', async () => { + settings.agentLaunchCommands = { claude: 'available-wrapper claude' } + deps.executable = async (bin) => bin === 'claude' ? null : bin + await expect(planner().plan(agent(), nodeId)).rejects.toThrow('agent CLI') + settings.agentLaunchCommands = { claude: 'missing-wrapper claude' } + deps.executable = async (bin) => bin === 'missing-wrapper' ? null : bin + await expect(planner().plan(agent(), nodeId)).rejects.toThrow('launcher') + settings.agentLaunchCommands = { claude: '${env:ABSENT} claude' } + deps.executable = async (bin) => bin + await expect(planner().plan(agent(), nodeId)).rejects.toThrow('launch is unavailable') + settings.defaultShell = 'python3' + await expect(planner().plan(agent(), nodeId)).rejects.toThrow('cannot launch') + }) + + it('admits shared shell and environment only after trust, and invalidates an earlier trust verdict', async () => { + snapshot = { shared: { version: 1, rev: 1, savedAt: '', + agents: { env: { QA: 'shared-value' } }, terminal: { shell: '/bin/zsh' } }, local: undefined } + const untrusted = await planner().plan(agent(), nodeId) + expect(untrusted.options.shell).toBe('/bin/bash') + deps.trust.isTrusted = async () => true + expect(await planner().revalidate(agent(), untrusted)).toBe(false) + const trusted = await planner().plan(agent(), nodeId) + expect(trusted.options.shell).toBe('/bin/zsh') + deps.trust.isTrusted = async () => false + expect(await planner().revalidate(agent(), trusted)).toBe(false) + snapshot.local = { terminal: { shell: '/bin/fish' } } + expect((await planner().plan(agent(), nodeId)).options.shell).toBe('/bin/fish') + }) + + it('binds an explicitly frozen default or selected local account, color and directory without reading credentials', async () => { + settings.claudeAccounts = [{ id: 'local', label: 'Local', createdAt: 1, color: '#112233' }] + await fs.mkdir(path.join(root, 'claude-accounts', 'local'), { recursive: true }) + project.defaultAccountId = 'local' + const req = { ...agent(), accountId: project.defaultAccountId } + const plan = await planner().plan(req, nodeId) + expect(plan.node).toMatchObject({ accountId: 'local', color: '#112233' }) + expect(plan.options.accountId).toBe('local') + settings.claudeAccounts = [] + expect(await planner().revalidate(req, plan)).toBe(false) + await expect(planner().plan({ ...agent(), accountId: 'local' }, nodeId)).rejects.toThrow('unavailable') + }) + + it('keeps an absent account as explicit System even when a project default is configured', async () => { + project.defaultAccountId = 'local-default' + settings.claudeAccounts = [{ id: 'local-default', label: 'Default', createdAt: 1 }] + await fs.mkdir(path.join(root, 'claude-accounts', 'local-default'), { recursive: true }) + const plan = await planner().plan(agent(), nodeId) + expect(plan.node.accountId).toBeUndefined() + expect(plan.options.accountId).toBeUndefined() + settings.claudeAccounts = [] + await expect(planner().plan({ ...agent(), accountId: project.defaultAccountId }, nodeId)).rejects.toThrow('unavailable') + }) + + it('refuses pending, remote, duplicate, missing-directory and other-agent accounts', async () => { + const req = { ...agent(), accountId: 'local' } + await fs.mkdir(path.join(root, 'claude-accounts', 'local'), { recursive: true }) + for (const rows of [[], [{ id: 'local', pending: true }], [{ id: 'local', host: 'remote' }], [{ id: 'local' }, { id: 'local' }]]) { + settings.claudeAccounts = rows as Settings['claudeAccounts'] + await expect(planner().plan(req, nodeId)).rejects.toThrow() + } + settings.claudeAccounts = [{ id: 'local', label: 'Local', createdAt: 1 }] + await fs.rm(path.join(root, 'claude-accounts', 'local'), { recursive: true }) + await expect(planner().plan(req, nodeId)).rejects.toThrow() + await expect(planner().plan({ ...agent('gemini'), accountId: 'local' }, nodeId)).rejects.toThrow('cannot use') + settings.codexAccounts = [{ id: 'codex-local', label: 'C', color: '#abcdef' }] + await fs.mkdir(path.join(root, 'codex-test', 'codex-local'), { recursive: true }) + expect((await planner().plan({ ...agent('codex'), accountId: 'codex-local' }, nodeId)).node.color).toBe('#abcdef') + }) + + it('keeps a valid linked account and refuses malformed linked paths rather than selecting a managed fallback', async () => { + const linked = path.join(root, 'linked') + await fs.mkdir(linked) + await fs.mkdir(path.join(root, 'claude-accounts', 'linked'), { recursive: true }) + settings.claudeAccounts = [{ id: 'linked', label: 'Linked', createdAt: 1, configDir: linked }] + expect((await planner().plan({ ...agent(), accountId: 'linked' }, nodeId)).options.accountId).toBe('linked') + settings.claudeAccounts[0].configDir = '../wrong' + await expect(planner().plan({ ...agent(), accountId: 'linked' }, nodeId)).rejects.toThrow('linked account') + }) + + it('uses the actual shared Codex launcher path and host default gateway model', async () => { + deps.codexIdentity = async () => ({ shared: true, launcherPath: '/generated/nodeterm-codex' }) + deps.executable = async (bin) => bin === 'nodeterm-codex' ? null : bin + settings.agentLaunchMode = 'gateway-model' + settings.modelGatewayDefaultModel = 'host-model' + const plan = await planner().plan(agent('codex'), nodeId) + expect(plan.command).toMatch(/^nodeterm-codex /) + expect(plan.command).toContain("--model 'host-model'") + expect(plan.node.agentModel).toBe('host-model') + deps.executable = async (bin) => bin.startsWith('/generated/') ? null : bin + await expect(planner().plan(agent('codex'), nodeId)).rejects.toThrow('shared Codex launcher') + settings.agentLaunchMode = 'subscription' + deps.codexIdentity = async () => ({ shared: false, launcherPath: null }) + expect((await planner().plan(agent('codex'), nodeId)).node.agentModel).toBeUndefined() + }) + + it('revalidates environment and shell trust changes, account/folder replacement and host policy changes', async () => { + snapshot = { shared: null, local: { agents: { env: { QA: 'before' } }, terminal: { shell: '/bin/bash' } } } + const plan = await planner().plan(agent(), nodeId) + snapshot.local!.agents!.env!.QA = 'after' + expect(await planner().revalidate(agent(), plan)).toBe(false) + snapshot.local!.agents!.env!.QA = 'before' + settings.claudePermissionMode = 'plan' + expect(await planner().revalidate(agent(), plan)).toBe(false) + settings.claudePermissionMode = DEFAULT_SETTINGS.claudePermissionMode + await fs.rename(project.cwd!, project.cwd! + '-old') + await fs.mkdir(project.cwd!) + expect(await planner().revalidate(agent(), plan)).toBe(false) + }) + + it('keeps the policy fence after appending its own node while rejecting a closed or missing owner', async () => { + const plan = await planner().plan(agent(), nodeId) + project.nodes.push(plan.node) + expect(await planner().revalidate(agent(), plan)).toBe(true) + project.closed = true + expect(await planner().revalidate(agent(), plan)).toBe(false) + project.closed = false + workspace.projects = [] + expect(await planner().revalidate(agent(), plan)).toBe(false) + expect(await planner().revalidate(agent(), { ...plan, validation: undefined })).toBe(false) + }) + + for (const surface of ['main', 'server']) { + it(`${surface} startup waits for ownership and wires supported tmux creation, verification and host policy`, async () => { + const source = await fs.readFile(new URL(`../${surface}/index.ts`, import.meta.url), 'utf8') + const at = source.indexOf('const managedPlanner = createManagedTerminalPlanner(') + expect(at).toBeGreaterThan(0) + expect(source.lastIndexOf('await workspaceStore.load(', at)).toBeGreaterThan(0) + if (surface === 'main') expect(source.slice(at - 200, at)).toContain('await workspaceStore.load({ sideline: false })') + const wiring = source.slice(at, source.indexOf('managedTerminals)', at) + 'managedTerminals)'.length) + for (const needed of ['store: workspaceStore', 'settings: () => settingsStore.get()', 'trust: projectTrustStore', + 'executable: findInLoginPath', 'sessionShell: resolveLocalSessionShell', 'claudeCaps: claudeCliCaps', + 'codexCaps: codexCliCaps', 'grokCaps: grokCliCaps', 'codexIdentity: codexIdentityCaps', + 'ptyManager.supportsManagedCreation() ? new ManagedTerminals(', + 'supported: () => ptyManager.supportsManagedCreation(), ...managedPlanner,\n create:', + 'ptyManager.createManagedHeadless(options, creationId, current)', + 'ptyManager.verifyManagedPane(receipt, current)', 'ptyManager.deliverManagedLaunch(receipt, command, current)']) { + expect(wiring, needed).toContain(needed) + } + expect(wiring).toContain(surface === 'main' ? 'hostBridge.nodeActions, true, managedTerminals)' : 'undefined, false, managedTerminals)') + }) + } +}) diff --git a/src/core/managed-terminal-plan.ts b/src/core/managed-terminal-plan.ts new file mode 100644 index 0000000000..9ebe56313c --- /dev/null +++ b/src/core/managed-terminal-plan.ts @@ -0,0 +1,200 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import { + AGENT_CONFIG, BUILTIN_AGENT_IDS, canSwitchModel, gatePermissionMode, + resolvePermissionMode, supportsSessionIdFlag, type BuiltinAgentId +} from '../shared/agents/config' +import { agentAccountColor } from '../shared/agents/account-color' +import { assembleLaunchCommand } from '../shared/agents/launch' +import { isLaunchShell } from '../shared/agents/pane' +import { isSafeAccountId } from '../shared/codex-account' +import { safeSessionProgram } from '../shared/node-exec' +import { resolveProjectSettings } from '../shared/project-settings' +import { shellSplit } from '../shared/shell-quote' +import type { ClaudeCliCaps, CodexCliCaps, CodexIdentityCaps, GrokCliCaps, Settings } from '../shared/types' +import { accountConfigDir, normalizeLinkedConfigDir } from './claude-accounts-core' +import { codexAccountHome } from './codex-accounts-core' +import type { ManagedTerminalPlan, ManagedTerminalRequest } from './managed-terminals' +import type { ProjectTrustStore } from './project-trust-store' +import { projectFamilyTrusted, projectTrustKeyFor } from './project-trust-verdict' +import type { WorkspaceStore } from './workspace-store' + +export interface ManagedTerminalPlannerDeps { + store: Pick + userData: string + settings(): Settings + trust: Pick + executable(program: string): Promise + sessionShell(program: string | undefined, defaultShell: string): string + claudeCaps(): Promise + codexCaps(): Promise + grokCaps(): Promise + codexIdentity(): Promise> + env?: Record +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const FIELDS = new Set(['creationId', 'projectId', 'kind', 'agentId', 'accountId', 'title', 'cols', 'rows']) +const CONTROL = /[\p{Cc}\u2028\u2029]/u +function refuse(message: string): never { throw new Error(message) } +function text(value: unknown, cap: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= cap && !CONTROL.test(value) +} +function validate(request: ManagedTerminalRequest, nodeId: string): void { + if (!request || typeof request !== 'object' || Array.isArray(request) || + Object.keys(request).some((key) => !FIELDS.has(key)) || !UUID.test(request.creationId) || + !text(request.projectId, 200) || !/^[A-Za-z0-9._-]{1,256}$/.test(nodeId) || + !Number.isInteger(request.cols) || request.cols < 2 || request.cols > 500 || + !Number.isInteger(request.rows) || request.rows < 2 || request.rows > 500 || + (request.title !== undefined && !text(request.title, 120))) refuse('Invalid managed terminal request.') + if (request.kind === 'shell') { + if (request.agentId !== undefined || request.accountId !== undefined) refuse('A shell cannot select an agent account.') + } else if (request.kind !== 'agent' || !BUILTIN_AGENT_IDS.includes(request.agentId as BuiltinAgentId)) { + refuse('This agent is not supported for managed SSH creation.') + } + if (request.accountId !== undefined && !isSafeAccountId(request.accountId)) refuse('Invalid managed account.') +} + +async function directoryIdentity(dir: string): Promise<{ real: string; dev: number; ino: number }> { + if (!path.isAbsolute(dir) || CONTROL.test(dir)) refuse('The project folder is unavailable.') + const real = await fs.realpath(dir) + const stat = await fs.stat(real) + if (!stat.isDirectory()) refuse('The project folder is unavailable.') + return { real, dev: stat.dev, ino: stat.ino } +} + +function providerSessionId(userData: string, request: ManagedTerminalRequest): string { + const bytes = createHash('sha256').update('nodeterm-managed-session-v1\0' + userData + '\0' + + request.projectId + '\0' + request.agentId + '\0' + request.creationId).digest().subarray(0, 16) + bytes[6] = (bytes[6] & 15) | 64 + bytes[8] = (bytes[8] & 63) | 128 + const hex = bytes.toString('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +/** No executable material comes from the phone. The plan stays private to the host coordinator. */ +export function createManagedTerminalPlanner(deps: ManagedTerminalPlannerDeps): { + plan(request: ManagedTerminalRequest, nodeId: string): Promise + revalidate(request: ManagedTerminalRequest, plan: ManagedTerminalPlan): Promise +} { + const plan = async (request: ManagedTerminalRequest, nodeId: string): Promise => { + validate(request, nodeId) + const workspace = await deps.store.load({ sideline: false }) + const matches = workspace.projects.filter((p) => p.id === request.projectId) + if (matches.length !== 1) refuse('The project is unavailable or ambiguous.') + const project = matches[0] + const info = deps.store.projectTargetInfo(request.projectId) + if (!info?.cwd || info.ssh || project.ssh || project.closed || !project.cwd || + (project as unknown as { drivenRemotely?: boolean }).drivenRemotely || info.cwd !== project.cwd) { + refuse('Managed SSH creation requires an open local folder project.') + } + const folder = await directoryIdentity(info.cwd) + const snapshot = await deps.store.readProjectSettings(request.projectId) + if (snapshot?.conflict) refuse('Resolve the project settings conflict before creating a terminal.') + const resolved = resolveProjectSettings(snapshot?.local, snapshot?.shared ?? undefined) + const key = projectTrustKeyFor(info) + const shared = snapshot?.shared ?? {} + const trustedAgents = await projectFamilyTrusted(deps.trust, key, 'agents', shared) + const trustedShell = await projectFamilyTrusted(deps.trust, key, 'shell', shared) + const admitted = (setting: { source: string; value: T } | undefined, trusted: boolean): T | undefined => + setting && (setting.source !== 'shared' || trusted) ? setting.value : undefined + const effectiveEnv = admitted(resolved.agents.env, trustedAgents) + const projectShell = admitted(resolved.terminal.shell, trustedShell) + if (projectShell && !safeSessionProgram(projectShell)) refuse('The configured project shell is unsupported.') + const settings = deps.settings() + const shell = deps.sessionShell(projectShell, settings.defaultShell) + if (!safeSessionProgram(shell) || !await deps.executable(shell)) refuse('The configured shell is unavailable.') + const agentId = request.kind === 'agent' ? request.agentId as BuiltinAgentId : undefined + if (agentId && !isLaunchShell(shell)) refuse('The configured shell cannot launch this agent safely.') + let accountId: string | undefined + let accountIdentity: unknown + // V1 absence is an explicit System choice. The phone freezes any displayed project default + // by sending its account id; consulting the mutable project default here would undo System. + if (request.accountId !== undefined) { + if (agentId !== 'claude' && agentId !== 'codex') refuse('This agent cannot use a managed account.') + accountId = request.accountId + if (!accountId || !isSafeAccountId(accountId)) refuse('The managed account is unavailable.') + const accounts = agentId === 'claude' ? settings.claudeAccounts : settings.codexAccounts + const rows = Array.isArray(accounts) ? accounts.filter((a) => a?.id === accountId) : [] + if (rows.length !== 1 || rows[0].host || rows[0].pending) refuse('The managed account is unavailable on this computer.') + const row = rows[0] + const linked = 'configDir' in row && row.configDir !== undefined + ? normalizeLinkedConfigDir(row.configDir) : undefined + if ('configDir' in row && row.configDir !== undefined && !linked) refuse('The linked account directory is unavailable.') + const dir = linked || (agentId === 'claude' + ? accountConfigDir(deps.userData, accountId) : codexAccountHome(deps.userData, accountId)) + accountIdentity = { row: { ...row }, directory: await directoryIdentity(dir) } + } + let command: string | undefined + let sessionId: string | undefined + let model: string | undefined + let agentFacts: unknown + if (agentId) { + const cli = await deps.executable(AGENT_CONFIG[agentId].launchCmd) + if (!cli) refuse('The selected agent CLI is unavailable on this computer.') + // Only the project-declared agent consumes its paired launch override. Shared command + // tokens never read this process environment; untrusted shared values use the global layer. + const paired = resolved.agents.defaultAgentId?.value === agentId + const projectCommand = paired ? admitted(resolved.agents.launchCmd, trustedAgents)?.trim() : undefined + const override = projectCommand && !projectCommand.includes('${env:') + ? projectCommand : settings.agentLaunchCommands?.[agentId]?.trim() || undefined + if (override && (CONTROL.test(override) || override.length > 4096)) refuse('The agent launch override is unsupported.') + const [claude, codex, grok, identity] = await Promise.all([ + agentId === 'claude' ? deps.claudeCaps() : null, + agentId === 'codex' ? deps.codexCaps() : null, + agentId === 'grok' ? deps.grokCaps() : null, + agentId === 'codex' ? deps.codexIdentity() : null + ]) + const sharedIdentity = identity?.shared === true + if (sharedIdentity && (!identity?.launcherPath || !await deps.executable(identity.launcherPath))) { + refuse('The shared Codex launcher is unavailable.') + } + const mode = resolvePermissionMode(project, settings) + const permissionMode = agentId === 'claude' ? gatePermissionMode(mode, claude?.autoPermissionMode === true) : mode + const sessionIdFlagSupported = supportsSessionIdFlag(agentId, claude?.sessionIdFlag === true, grok?.sessionIdFlag === true) + sessionId = sessionIdFlagSupported ? providerSessionId(deps.userData, request) : undefined + model = settings.agentLaunchMode === 'gateway-model' && canSwitchModel(agentId) + ? settings.modelGatewayDefaultModel || undefined : undefined + if (model && (!text(model, 1024))) refuse('The configured agent model is invalid.') + const assembled = assembleLaunchCommand({ agentId, launchCmdOverride: override, permissionMode, + sessionId, sessionIdFlagSupported, sharedIdentity, + approvalCaps: { codexApprovalValues: codex?.approvalValues }, model }, deps.env ?? process.env) + if (assembled.missingEnv.length || !assembled.command || CONTROL.test(assembled.command) || + Buffer.byteLength(assembled.command) > 30_000) refuse('The configured agent launch is unavailable.') + command = assembled.command + // A machine-local wrapper is still real executable input: validate its leading program + // rather than silently accepting a missing wrapper and falling back to the bare CLI. + const leading = shellSplit(command).find((word) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) + if (!leading || !(sharedIdentity && !override && leading === 'nodeterm-codex') && !await deps.executable(leading)) { + refuse('The configured agent launcher is unavailable.') + } + agentFacts = { cli, claude, codex, grok, identity, override, permissionMode, model } + } + const node = { + id: nodeId, kind: 'terminal' as const, cwd: info.cwd, shell, + title: request.title ?? (agentId ? AGENT_CONFIG[agentId].label : 'Terminal'), + color: agentId ? agentAccountColor(agentId, accountId, { + claude: Array.isArray(settings.claudeAccounts) ? settings.claudeAccounts : [], + codex: Array.isArray(settings.codexAccounts) ? settings.codexAccounts : [] + }) ?? AGENT_CONFIG[agentId].color : '#0a84ff', + position: { x: 80 + (project.nodes.length % 4) * 40, y: 80 + (project.nodes.length % 4) * 40 }, + size: { width: 640, height: 440 }, group: null, + ...(agentId ? { agentId, titleAuto: request.title === undefined } : {}), + ...(accountId ? { accountId } : {}), ...(model ? { agentModel: model } : {}), + ...(sessionId ? { agentSessionId: sessionId } : {}) + } + const options = { persistKey: nodeId, ownerProjectId: request.projectId, cwd: info.cwd, shell, + cols: request.cols, rows: request.rows, ...(agentId ? { agentId } : {}), + ...(accountId ? { accountId } : {}), ...(model ? { agentModel: model } : {}) } + const validation = createHash('sha256').update(JSON.stringify({ projectId: request.projectId, + folder, options, command, accountIdentity, agentFacts, effectiveEnv, projectShell, + launchMode: settings.agentLaunchMode, gateway: settings.modelGateway, + trustedAgents, trustedShell })).digest('hex') + return { node, options, command, validation } + } + return { plan, revalidate: async (request, previous) => { + try { return !!previous.validation && (await plan(request, previous.node.id)).validation === previous.validation } + catch { return false } + } } +} diff --git a/src/core/managed-terminals.test.ts b/src/core/managed-terminals.test.ts new file mode 100644 index 0000000000..684b04a0ac --- /dev/null +++ b/src/core/managed-terminals.test.ts @@ -0,0 +1,361 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { randomUUID } from 'node:crypto' +import { initPlatform, resetPlatformForTests } from './platform' +import { fakePlatform } from './platform-fake' +import { WorkspaceStore } from './workspace-store' +import { ManagedTerminals, parseManagedTerminalRequest, type ManagedTerminalDependencies, type ManagedTerminalPlan, MANAGED_RECOVERY_COMMAND } from './managed-terminals' +import { SshActionsService, readSshActionFile } from './ssh-actions' +import type { ManagedPaneReceipt } from '../shared/managed-terminal' +import type { Project } from '../shared/types' + +let root: string, cwd: string, store: WorkspaceStore, deps: ManagedTerminalDependencies, creator: ManagedTerminals, instance: string, fake: ReturnType +const shell = () => ({ creationId: randomUUID(), projectId: 'p1', kind: 'shell', cols: 80, rows: 24 }) +const agent = () => ({ ...shell(), kind: 'agent', agentId: 'claude', accountId: 'personal', title: 'Owned Claude' }) +const file = () => path.join(cwd, '.nodeterm/project.json') +const read = async () => JSON.parse(await fs.readFile(file(), 'utf8')) +const deferred = () => { let resolve!: () => void; const promise = new Promise((r) => { resolve = r }); return { promise, resolve } } +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'nt-managed-create-')); cwd = path.join(root, 'project'); await fs.mkdir(cwd) + fake = fakePlatform({ userDataDir: root }); initPlatform(fake); store = new WorkspaceStore(); instance = randomUUID() + const project: Project = { id: 'p1', name: 'owned', color: '#fff', cwd, nodes: [], viewport: { x: 0, y: 0, zoom: 1 } } + await store.save({ version: 2, activeProjectId: 'p1', projects: [project] }) + deps = { + supported: () => true, + plan: vi.fn(async (request, nodeId): Promise => ({ + node: { id: nodeId, kind: 'terminal', title: request.title ?? 'Mobile session', titleAuto: true, color: '#fff', + cwd, position: { x: 100, y: 100 }, size: { width: 900, height: 560 }, group: null, + ...(request.agentId ? { agentId: request.agentId, accountId: request.accountId } : {}) }, + options: { persistKey: nodeId, ownerProjectId: request.projectId, cwd, cols: request.cols, rows: request.rows, + agentId: request.agentId, accountId: request.accountId }, + ...(request.kind === 'agent' ? { command: 'claude --session-id ' + request.creationId } : {}) + })), + revalidate: vi.fn(async () => true), + create: vi.fn(async (options, creationId): Promise => ({ version: 1, creationId, nodeId: options.persistKey!, + projectId: options.ownerProjectId!, socket: 'node-terminal', session: 'nt-' + options.persistKey, + paneId: '%7', panePid: 4123, paneBirth: 'linux:owned-boot:100', sessionCreated: '1700000000' })), + verify: vi.fn(async () => true), deliver: vi.fn(async () => true) + } + creator = new ManagedTerminals(root, store, deps) +}) +afterEach(async () => { vi.restoreAllMocks(); resetPlatformForTests(); await fs.rm(root, { recursive: true, force: true }) }) + +// This authenticated creation path and its private UID/fsync journal are POSIX-only. +describe.skipIf(process.platform === 'win32')('host-owned managed terminal creation', () => { + it('creates a real pane before publishing its explicit local shell facts and returns a versioned handoff', async () => { + const request = shell() + vi.mocked(deps.create).mockImplementationOnce(async (options, id, current) => { + expect(current()).toBe(true); expect((await read()).nodes).toHaveLength(0) + const accepted = JSON.parse(await fs.readFile(path.join(creator.directory, id + '.json'), 'utf8')) + expect(accepted.phase).toBe('prepared'); expect(accepted.node.id).toBe(options.persistKey) + return { version: 1, creationId: id, nodeId: options.persistKey!, projectId: 'p1', socket: 'node-terminal', session: 'nt-' + options.persistKey, + paneId: '%7', panePid: 4123, paneBirth: 'linux:owned-boot:100', sessionCreated: '1700000000' } + }) + const receipt = await creator.create(request, instance, () => true) + expect(receipt).toMatchObject({ version: 1, hostInstance: instance, creationId: request.creationId, projectId: 'p1', socket: 'node-terminal' }) + const node = (await read()).nodes[0] + expect(node).toMatchObject({ id: receipt.nodeId, cwd: '.', kind: 'terminal' }); expect(node.pendingLaunch).toBeUndefined() + expect(deps.deliver).not.toHaveBeenCalled(); expect(deps.verify).toHaveBeenCalled() + expect((await fs.stat(creator.directory)).mode & 0o777).toBe(0o700) + expect((await fs.stat(path.join(creator.directory, request.creationId + '.json'))).mode & 0o777).toBe(0o600) + }) + it('publishes guarded manual agent intent before one launch and clears it only after confirmed submission', async () => { + const request = agent(); let nodeId = '' + vi.mocked(deps.deliver).mockImplementationOnce(async (receipt, command, current) => { + nodeId = receipt.nodeId; expect(current()).toBe(true) + expect((await read()).nodes[0]).toMatchObject({ id: nodeId, cwd: '.', agentId: 'claude', accountId: 'personal', + pendingLaunch: { command: MANAGED_RECOVERY_COMMAND, after: [], attempted: true, manualOnly: true, executor: 'server' } }) + const privateRecord = JSON.parse(await fs.readFile(path.join(creator.directory, request.creationId + '.json'), 'utf8')) + expect(privateRecord.phase).toBe('launchAttempted'); expect(privateRecord.command).toBe(command) + return true + }) + const receipt = await creator.create(request, instance, () => true) + expect(receipt.nodeId).toBe(nodeId); expect((await read()).nodes[0].pendingLaunch).toBeUndefined() + expect(deps.deliver).toHaveBeenCalledTimes(1) + }) + it('keeps host-expanded credential text out of the shared project and renderer intent', async () => { + const original = deps.plan + deps.plan = vi.fn(async (...args: Parameters) => ({ ...await original(...args), command: 'SECRET_TOKEN=private-synthetic-secret claude' })) + deps.deliver = vi.fn(async (_receipt, command) => { + expect(command).toContain('private-synthetic-secret') + expect(await fs.readFile(file(), 'utf8')).not.toContain('private-synthetic-secret') + expect((await read()).nodes[0].pendingLaunch.command).toBe(MANAGED_RECOVERY_COMMAND) + return false + }) + await expect(creator.create(agent(), instance, () => true)).rejects.toMatchObject({ uncertain: true }) + expect(await fs.readFile(file(), 'utf8')).not.toContain('private-synthetic-secret') + expect(JSON.stringify(fake.sent)).not.toContain('private-synthetic-secret') + }) + it('syncs each private phase file and parent before pane creation and launch submission', async () => { + const probe = await fs.open(path.join(root, 'sync-probe'), 'wx', 0o600) + const prototype = Object.getPrototypeOf(probe), originalSync = prototype.sync; await probe.close() + const ownerInode = (await fs.stat(root)).ino + const events: string[] = [] + vi.spyOn(prototype, 'sync').mockImplementation(async function (this: any) { + const stat = await this.stat() + events.push(stat.isDirectory() ? stat.ino === ownerInode ? 'owner' : 'directory' : 'file') + return originalSync.call(this) + }) + const originalCreate = deps.create, originalDeliver = deps.deliver + deps.create = vi.fn(async (...args: Parameters) => { expect(events).toEqual(['owner', 'file', 'directory']); events.push('create'); return originalCreate(...args) }) + deps.deliver = vi.fn(async (...args: Parameters) => { expect(events.slice(-2)).toEqual(['file', 'directory']); events.push('deliver'); return originalDeliver(...args) }) + await creator.create(agent(), instance, () => true) + expect(events.filter((event) => event === 'file')).toHaveLength(5) + expect(events.filter((event) => event === 'directory')).toHaveLength(5) + expect(events).toContain('create'); expect(events).toContain('deliver') + }) + it('refuses creation before journaling when the new directory parent cannot be synced', async () => { + const probe = await fs.open(path.join(root, 'sync-probe'), 'wx', 0o600) + const prototype = Object.getPrototypeOf(probe), originalSync = prototype.sync; await probe.close() + const ownerInode = (await fs.stat(root)).ino + vi.spyOn(prototype, 'sync').mockImplementation(async function (this: any) { + const stat = await this.stat() + if (stat.isDirectory() && stat.ino === ownerInode) throw new Error('synthetic owner sync failure') + return originalSync.call(this) + }) + const request = agent() + await expect(creator.create(request, instance, () => true)).rejects.toThrow(/owner sync failure/) + expect(deps.plan).not.toHaveBeenCalled(); expect(deps.create).not.toHaveBeenCalled(); expect(deps.deliver).not.toHaveBeenCalled() + await expect(fs.stat(path.join(creator.directory, request.creationId + '.json'))).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await read()).nodes).toHaveLength(0) + }) + it('accepts an owned readable parent but refuses writable or symlinked profile parents before creation', async () => { + await fs.chmod(root, 0o755) + await creator.create(shell(), instance, () => true) + expect(deps.create).toHaveBeenCalledTimes(1) + await fs.chmod(root, 0o777) + await expect(creator.create(shell(), instance, () => true)).rejects.toThrow(/Unsafe.*parent/) + await fs.chmod(root, 0o700) + const link = root + '-link' + await fs.symlink(root, link) + try { await expect(new ManagedTerminals(link, store, deps).create(shell(), instance, () => true)).rejects.toThrow(/Unsafe.*parent/) } + finally { await fs.unlink(link) } + expect(deps.create).toHaveBeenCalledTimes(1) + }) + it.each(['file', 'directory'])('never creates a pane after the prepared %s sync barrier failed', async (failure) => { + const probe = await fs.open(path.join(root, 'sync-probe'), 'wx', 0o600) + const prototype = Object.getPrototypeOf(probe), originalSync = prototype.sync; await probe.close() + const ownerInode = (await fs.stat(root)).ino + vi.spyOn(prototype, 'sync').mockImplementation(async function (this: any) { + const stat = await this.stat() + const kind = stat.isDirectory() ? stat.ino === ownerInode ? 'owner' : 'directory' : 'file' + if (kind === failure) throw new Error('synthetic sync failure') + return originalSync.call(this) + }) + const request = agent() + await expect(creator.create(request, instance, () => true)).rejects.toMatchObject({ uncertain: true }) + expect(deps.create).not.toHaveBeenCalled(); expect(deps.deliver).not.toHaveBeenCalled() + if (failure === 'directory') expect(JSON.parse(await fs.readFile(path.join(creator.directory, request.creationId + '.json'), 'utf8')).phase).toBe('prepared') + }) + it('leaves visible recovery without submitting after the launch-attempt directory sync failed', async () => { + const probe = await fs.open(path.join(root, 'sync-probe'), 'wx', 0o600) + const prototype = Object.getPrototypeOf(probe), originalSync = prototype.sync; await probe.close() + const request = agent() + const ownerInode = (await fs.stat(root)).ino + vi.spyOn(prototype, 'sync').mockImplementation(async function (this: any) { + const stat = await this.stat() + if (stat.isDirectory() && stat.ino !== ownerInode) { + const phase = JSON.parse(await fs.readFile(path.join(creator.directory, request.creationId + '.json'), 'utf8')).phase + if (phase === 'launchAttempted') throw new Error('synthetic sync failure') + } + return originalSync.call(this) + }) + await expect(creator.create(request, instance, () => true)).rejects.toMatchObject({ uncertain: true }) + expect(deps.deliver).not.toHaveBeenCalled(); expect((await read()).nodes[0].pendingLaunch.manualOnly).toBe(true) + }) + it('serializes concurrent identical creations and verifies the same pane without a second launch', async () => { + const request = agent(); const gate = deferred(); const entered = deferred() + vi.mocked(deps.create).mockImplementationOnce(async (options, id) => { entered.resolve(); await gate.promise; return { + version: 1, creationId: id, nodeId: options.persistKey!, projectId: 'p1', socket: 'node-terminal', session: 'nt-' + options.persistKey, + paneId: '%7', panePid: 4123, paneBirth: 'linux:owned-boot:100', sessionCreated: '1700000000' } }) + const a = creator.create(request, instance, () => true); await entered.promise + const b = creator.create({ ...request }, instance, () => true); gate.resolve() + expect(await b).toEqual(await a); expect(deps.create).toHaveBeenCalledTimes(1); expect(deps.deliver).toHaveBeenCalledTimes(1) + expect((await read()).nodes).toHaveLength(1); expect(deps.verify).toHaveBeenCalledTimes(2) + }) + it('refuses changed choices under the same durable creation id, including dimensions', async () => { + const request = shell(); const receipt = await creator.create(request, instance, () => true) + await expect(creator.create({ ...request, cols: 90 }, instance, () => true)).rejects.toMatchObject({ uncertain: true, recoveryNodeId: receipt.nodeId }) + expect(deps.create).toHaveBeenCalledTimes(1) + }) + it('never rehydrates a completed journal into pane ownership after service restart', async () => { + const request = agent(); const receipt = await creator.create(request, instance, () => true) + creator = new ManagedTerminals(root, store, deps) + await expect(creator.create(request, randomUUID(), () => true)).rejects.toMatchObject({ uncertain: true, recoveryNodeId: receipt.nodeId }) + expect(deps.create).toHaveBeenCalledTimes(1); expect(deps.deliver).toHaveBeenCalledTimes(1) + }) + it('does not return a cached success when the original pane was replaced', async () => { + const request = shell(); const receipt = await creator.create(request, instance, () => true) + vi.mocked(deps.verify).mockResolvedValue(false) + await expect(creator.create(request, instance, () => true)).rejects.toMatchObject({ uncertain: true, recoveryNodeId: receipt.nodeId }) + expect(deps.create).toHaveBeenCalledTimes(1) + }) + it('does not reuse a confirmed receipt after the saved node was deleted', async () => { + const request = shell(); const receipt = await creator.create(request, instance, () => true) + const doc = await read(); doc.nodes = []; await fs.writeFile(file(), JSON.stringify(doc)) + await expect(creator.create(request, instance, () => true)).rejects.toMatchObject({ uncertain: true, recoveryNodeId: receipt.nodeId }) + expect(deps.create).toHaveBeenCalledTimes(1) + }) + it('refuses launch after the saved pending command was removed while policy resolution awaited', async () => { + const original = deps.revalidate; let calls = 0 + deps.revalidate = vi.fn(async (...args: Parameters) => { + if (++calls === 3) { const doc = await read(); delete doc.nodes[0].pendingLaunch; await fs.writeFile(file(), JSON.stringify(doc)) } + return original(...args) + }) + await expect(creator.create(agent(), instance, () => true)).rejects.toMatchObject({ uncertain: true }) + expect(deps.deliver).not.toHaveBeenCalled() + }) + it('passes a saved-intent fence through delayed pane attestation before launch input', async () => { + const gate = deferred(), entered = deferred(); let wrote = false + deps.deliver = vi.fn(async (_receipt, _command, current) => { entered.resolve(); await gate.promise; if (current()) wrote = true; return wrote }) + const operation = creator.create(agent(), instance, () => true); await entered.promise + const doc = await read(); doc.nodes[0].accountId = 'replacement'; await fs.writeFile(file(), JSON.stringify(doc)); gate.resolve() + await expect(operation).rejects.toMatchObject({ uncertain: true }) + expect(wrote).toBe(false); expect((await read()).nodes[0].pendingLaunch).toBeDefined() + }) + it('does not issue a shell handoff after workspace deletion during the final pane proof', async () => { + deps.verify = vi.fn(async () => { const doc = await read(); doc.nodes = []; await fs.writeFile(file(), JSON.stringify(doc)); return true }) + await expect(creator.create(shell(), instance, () => true)).rejects.toMatchObject({ uncertain: true }) + }) + it('retains an unconfirmed launch as manual-only visible recovery and never retries it', async () => { + const request = agent(); vi.mocked(deps.deliver).mockResolvedValue(false) + await expect(creator.create(request, instance, () => true)).rejects.toMatchObject({ uncertain: true }) + const node = (await read()).nodes[0] + expect(node.pendingLaunch).toMatchObject({ attempted: true, manualOnly: true, executor: 'server' }) + await expect(creator.create(request, instance, () => true)).rejects.toMatchObject({ uncertain: true, recoveryNodeId: node.id }) + expect(deps.deliver).toHaveBeenCalledTimes(1) + }) + it('retains a failed primitive as recovery without destructively guessing whether a pane exists', async () => { + vi.mocked(deps.create).mockRejectedValue(new Error('lost backend response')) + const request = agent() + await expect(creator.create(request, instance, () => true)).rejects.toMatchObject({ uncertain: true }) + expect((await read()).nodes).toHaveLength(1) + expect((await read()).nodes[0].pendingLaunch.manualOnly).toBe(true) + creator = new ManagedTerminals(root, store, deps) + await expect(creator.create(request, randomUUID(), () => true)).rejects.toMatchObject({ uncertain: true }) + expect(deps.create).toHaveBeenCalledTimes(1); expect(deps.deliver).not.toHaveBeenCalled() + }) + it('does not create or publish when trust resolution loses the instance', async () => { + const gate = deferred(), entered = deferred(); let current = true + const original = deps.plan + deps.plan = vi.fn(async (...args: Parameters) => { entered.resolve(); await gate.promise; return original(...args) }) + const operation = creator.create(agent(), instance, () => current); await entered.promise; current = false; gate.resolve() + await expect(operation).rejects.toThrow(/instance changed/) + expect(deps.create).not.toHaveBeenCalled(); expect((await read()).nodes).toHaveLength(0) + }) + it('fences a primitive finishing after STOP and performs no later publication or launch', async () => { + const gate = deferred(), entered = deferred(); let current = true + const original = deps.create + deps.create = vi.fn(async (...args: Parameters) => { entered.resolve(); await gate.promise; return original(...args) }) + const operation = creator.create(agent(), instance, () => current); await entered.promise; current = false; gate.resolve() + await expect(operation).rejects.toMatchObject({ uncertain: true }) + expect((await read()).nodes).toHaveLength(0); expect(deps.deliver).not.toHaveBeenCalled() + }) + it('revalidates host launch policy before delivery and leaves recovery intent if it changed', async () => { + vi.mocked(deps.revalidate).mockResolvedValueOnce(true).mockResolvedValueOnce(true).mockResolvedValue(false) + await expect(creator.create(agent(), instance, () => true)).rejects.toMatchObject({ uncertain: true }) + expect(deps.deliver).not.toHaveBeenCalled(); expect((await read()).nodes[0].pendingLaunch.attempted).toBe(true) + }) + it('fails closed when registration is refused and does not submit an unregistered launch', async () => { + vi.spyOn(store, 'appendManagedTerminal').mockResolvedValue(false) + await expect(creator.create(agent(), instance, () => true)).rejects.toMatchObject({ uncertain: true }) + expect(deps.deliver).not.toHaveBeenCalled(); expect((await read()).nodes).toHaveLength(0) + }) + it('cannot clear a replacement recovery intent after successful submission', async () => { + deps.deliver = vi.fn(async () => { const doc = await read(); doc.nodes[0].cwd = '/replacement'; await fs.writeFile(file(), JSON.stringify(doc)); return true }) + await expect(creator.create(agent(), instance, () => true)).rejects.toMatchObject({ uncertain: true }) + expect((await read()).nodes[0].pendingLaunch.manualOnly).toBe(true) + }) + it.each(['cwd', 'shell', 'agentId', 'accountId', 'agentModel', 'cols'])('refuses inconsistent host-plan %s identity before mutation', async (key) => { + const original = deps.plan + deps.plan = vi.fn(async (...args: Parameters) => { + const plan = await original(...args) + ;(plan.options as unknown as Record)[key] = key === 'cols' ? 90 : 'replacement' + return plan + }) + await expect(creator.create(agent(), instance, () => true)).rejects.toThrow(/Invalid host/) + expect(deps.create).not.toHaveBeenCalled(); expect((await read()).nodes).toHaveLength(0) + }) + it('refuses a forged primitive identity without launch or confirmed handoff', async () => { + const original = deps.create; deps.create = vi.fn(async (...args: Parameters) => ({ ...await original(...args), nodeId: 'term-forged-0' })) + await expect(creator.create(agent(), instance, () => true)).rejects.toMatchObject({ uncertain: true }) + expect(deps.deliver).not.toHaveBeenCalled() + }) + it('refuses unsafe journal directories and symlink records without touching their target', async () => { + const request = shell(), foreign = path.join(root, 'foreign'); await fs.mkdir(foreign, { mode: 0o700 }) + await fs.symlink(foreign, creator.directory) + await expect(creator.create(request, instance, () => true)).rejects.toThrow(/Unsafe/) + await fs.unlink(creator.directory); await fs.mkdir(creator.directory, { mode: 0o700 }) + const target = path.join(foreign, 'public'); await fs.writeFile(target, 'do not read', { mode: 0o600 }) + await fs.symlink(target, path.join(creator.directory, request.creationId + '.json')) + await expect(creator.create(request, instance, () => true)).rejects.toThrow() + expect(await fs.readFile(target, 'utf8')).toBe('do not read'); expect(deps.create).not.toHaveBeenCalled() + }) + it('cannot turn an unsafe completed journal into a cached success', async () => { + const request = shell(); await creator.create(request, instance, () => true) + await fs.chmod(path.join(creator.directory, request.creationId + '.json'), 0o644) + await expect(creator.create(request, instance, () => true)).rejects.toMatchObject({ uncertain: true }) + expect(deps.create).toHaveBeenCalledTimes(1) + }) + it('rejects unsafe journal file modes and never launches after operator-corrupted recovery', async () => { + const request = agent(); vi.mocked(deps.deliver).mockResolvedValue(false) + await expect(creator.create(request, instance, () => true)).rejects.toMatchObject({ uncertain: true }) + const journal = path.join(creator.directory, request.creationId + '.json'); await fs.chmod(journal, 0o644) + await expect(creator.create(request, instance, () => true)).rejects.toMatchObject({ uncertain: true }) + expect(deps.deliver).toHaveBeenCalledTimes(1) + }) + it.each([ + { cwd: '/caller' }, { command: 'echo injected' }, { nodeId: 'term-target-0' }, { kind: 'shell', agentId: 'claude' }, + { kind: 'agent', agentId: 'custom:untrusted' }, { accountId: '../account' }, { kind: 'agent', agentId: 'claude', accountId: '../account' }, { cols: 0 }, { rows: 501 }, { title: '\u001btitle' } + ])('refuses caller launch/identity fields and malformed choices %j', (changes) => { + expect(() => parseManagedTerminalRequest({ ...shell(), ...changes })).toThrow(/Invalid/) + }) + it('refuses unsupported backends without journaling, planning or spawning', async () => { + deps.supported = () => false + await expect(creator.create(shell(), instance, () => true)).rejects.toThrow(/enabled POSIX tmux/) + expect(deps.plan).not.toHaveBeenCalled(); expect(deps.create).not.toHaveBeenCalled() + }) + it('advertises creation only with an enabled host coordinator and executes the real service/store path', async () => { + const service = new SshActionsService(root, store, undefined, false, creator); await service.start() + try { + expect(service.methods).toContain('sessions.createManagedV1') + const nonce = randomUUID(), request = agent() + await fs.writeFile(path.join(service.directory, nonce + '.request'), JSON.stringify({ version: 1, instance: service.instance, + nonce, issuedAt: Date.now(), method: 'sessions.createManagedV1', params: request }), { mode: 0o600 }) + await service.tick() + const response = JSON.parse(await readSshActionFile(path.join(service.directory, nonce + '.response'))) + expect(response).toMatchObject({ ok: true, result: { hostInstance: service.instance, creationId: request.creationId, socket: 'node-terminal' } }) + expect((await read()).nodes[0].id).toBe(response.result.nodeId) + } finally { await service.stop() } + deps.supported = () => false + expect(new SshActionsService(root, store, undefined, false, creator).methods).not.toContain('sessions.createManagedV1') + expect(new SshActionsService(root, store).methods).not.toContain('sessions.createManagedV1') + }) + it('returns explicit uncertainty and a safe recovery id after a private launch failure', async () => { + deps.deliver = vi.fn(async () => { throw new Error('synthetic-secret-backend-error') }) + const service = new SshActionsService(root, store, undefined, false, creator); await service.start() + try { + const nonce = randomUUID(); await fs.writeFile(path.join(service.directory, nonce + '.request'), JSON.stringify({ version: 1, + instance: service.instance, nonce, issuedAt: Date.now(), method: 'sessions.createManagedV1', params: agent() }), { mode: 0o600 }) + await service.tick() + const raw = await readSshActionFile(path.join(service.directory, nonce + '.response')), response = JSON.parse(raw) + expect(response).toMatchObject({ ok: false, uncertain: true, recoveryNodeId: (await read()).nodes[0].id }) + expect(raw).not.toContain('synthetic-secret-backend-error') + expect((await read()).nodes[0].pendingLaunch).toMatchObject({ attempted: true, manualOnly: true, executor: 'server' }) + } finally { await service.stop() } + }) + it('keeps service claim until canceled in-flight creation settles and never publishes its late receipt', async () => { + const gate = deferred(), entered = deferred(), original = deps.create + deps.create = vi.fn(async (...args: Parameters) => { entered.resolve(); await gate.promise; return original(...args) }) + const service = new SshActionsService(root, store, undefined, false, creator); await service.start() + const nonce = randomUUID(); await fs.writeFile(path.join(service.directory, nonce + '.request'), JSON.stringify({ version: 1, + instance: service.instance, nonce, issuedAt: Date.now(), method: 'sessions.createManagedV1', params: agent() }), { mode: 0o600 }) + const processing = service.tick(); await entered.promise; const stopping = service.stop() + expect(await fs.stat(path.join(service.root, 'claim'))).toBeTruthy() + gate.resolve(); await stopping; await expect(processing).rejects.toThrow(/instance changed/) + expect((await read()).nodes).toHaveLength(0); expect(deps.deliver).not.toHaveBeenCalled() + await expect(fs.stat(path.join(service.directory, nonce + '.response'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/src/core/managed-terminals.ts b/src/core/managed-terminals.ts new file mode 100644 index 0000000000..97a4904a88 --- /dev/null +++ b/src/core/managed-terminals.ts @@ -0,0 +1,238 @@ +/** One host-owned create/launch/register transaction. Durable phases prevent a new service from + * replaying uncertain input; they never restore the runtime pane/messaging ownership ledger. */ +import nodeFs, { promises as fs, constants } from 'node:fs' +import path from 'node:path' +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { renameAtomicSync } from './fs-atomic' +import type { WorkspaceStore } from './workspace-store' +import type { CanvasNodeState, PtyCreateOptions } from '../shared/types' +import { BUILTIN_AGENT_IDS } from '../shared/agents/config' +import { isSafeAccountId } from './claude-accounts-core' +import type { ManagedPaneReceipt, ManagedTerminalReceipt } from '../shared/managed-terminal' + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const NODE = /^term-[a-z0-9]+-[a-z0-9]{1,16}$/ +// eslint-disable-next-line no-control-regex +const CONTROL = /[\u0000-\u001f\u007f-\u009f]/ +// The launch can contain host-expanded credentials. The shared node carries only this inert +// recovery intent; explicit inspection may recover the real command from the private journal. +export const MANAGED_RECOVERY_COMMAND = "printf '%s\\n' 'Managed launch was not confirmed. Inspect this terminal before starting the agent.'" +const MAX_JOURNAL_BYTES = 128 * 1024 +export interface ManagedTerminalRequest { + creationId: string + projectId: string + kind: 'shell' | 'agent' + agentId?: string + accountId?: string + title?: string + cols: number + rows: number +} +export interface ManagedTerminalPlan { + node: CanvasNodeState + options: PtyCreateOptions + command?: string + /** Opaque host-only policy/identity snapshot for the planner's revalidation. */ + validation?: string +} +export type { ManagedTerminalReceipt } from '../shared/managed-terminal' +export interface ManagedTerminalDependencies { + supported(): boolean + plan(request: ManagedTerminalRequest, nodeId: string): Promise + revalidate(request: ManagedTerminalRequest, plan: ManagedTerminalPlan): Promise + create(options: PtyCreateOptions, creationId: string, current: () => boolean): Promise + verify(receipt: ManagedPaneReceipt, current: () => boolean): Promise + deliver(receipt: ManagedPaneReceipt, command: string, current: () => boolean): Promise +} +type Phase = 'prepared' | 'paneCreated' | 'registered' | 'launchAttempted' | 'complete' +interface Journal { + version: 1 + creationId: string + fingerprint: string + hostInstance: string + node: CanvasNodeState + projectId: string + phase: Phase + command?: string + receipt?: ManagedPaneReceipt +} +export class ManagedTerminalUncertain extends Error { + readonly uncertain = true + constructor(message: string, readonly recoveryNodeId?: string) { super(message) } +} +export function parseManagedTerminalRequest(params: Record): ManagedTerminalRequest { + const keys = ['creationId', 'projectId', 'kind', 'agentId', 'accountId', 'title', 'cols', 'rows'] + if (Object.keys(params).some((k) => !keys.includes(k)) || + typeof params.creationId !== 'string' || !UUID.test(params.creationId) || + typeof params.projectId !== 'string' || !params.projectId || params.projectId.length > 200 || CONTROL.test(params.projectId) || + (params.kind !== 'shell' && params.kind !== 'agent') || + !Number.isInteger(params.cols) || (params.cols as number) < 2 || (params.cols as number) > 500 || + !Number.isInteger(params.rows) || (params.rows as number) < 2 || (params.rows as number) > 500 || + (params.title !== undefined && (typeof params.title !== 'string' || !params.title.trim() || params.title.length > 120 || CONTROL.test(params.title))) || + (params.accountId !== undefined && (typeof params.accountId !== 'string' || !isSafeAccountId(params.accountId))) || + (params.kind === 'shell' && (params.agentId !== undefined || params.accountId !== undefined)) || + (params.kind === 'agent' && !BUILTIN_AGENT_IDS.includes(params.agentId as never))) { + throw new Error('Invalid managed terminal creation request') + } + // Fixed key order binds all choices, including the requested initial dimensions. + return { creationId: params.creationId, projectId: params.projectId, kind: params.kind, + ...(params.agentId !== undefined ? { agentId: params.agentId as string } : {}), + ...(params.accountId !== undefined ? { accountId: params.accountId as string } : {}), + ...(params.title !== undefined ? { title: (params.title as string).trim() } : {}), + cols: params.cols as number, rows: params.rows as number } +} +const fingerprint = (request: ManagedTerminalRequest): string => createHash('sha256').update(JSON.stringify(request)).digest('hex') + +export class ManagedTerminals { + readonly directory: string + private readonly tails = new Map>() + private readonly completed = new Map() + constructor(userData: string, private readonly store: WorkspaceStore, private readonly dependencies: ManagedTerminalDependencies) { + this.directory = path.join(userData, 'managed-terminals') + } + supported(): boolean { return this.dependencies.supported() } + create(params: Record, hostInstance: string, current: () => boolean): Promise { + const request = parseManagedTerminalRequest(params) + const prior = this.tails.get(request.creationId) ?? Promise.resolve() + const operation = prior.catch(() => {}).then(() => this.createNow(request, hostInstance, current)) + this.tails.set(request.creationId, operation) + void operation.finally(() => { if (this.tails.get(request.creationId) === operation) this.tails.delete(request.creationId) }).catch(() => {}) + return operation + } + private check(current: () => boolean): void { if (!current()) throw new Error('Managed creation instance changed') } + private async privateDirectory(current: () => boolean): Promise { + const parent = path.dirname(this.directory) + const owner = await fs.lstat(parent); this.check(current) + if (!owner.isDirectory() || owner.isSymbolicLink() || owner.uid !== process.getuid?.() || (owner.mode & 0o022) !== 0) throw new Error('Unsafe managed creation journal parent') + await fs.mkdir(this.directory, { mode: 0o700 }).catch((e) => { if (e.code !== 'EEXIST') throw e }) + this.check(current) + const stat = await fs.lstat(this.directory); this.check(current) + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) throw new Error('Unsafe managed creation journal directory') + // Persist the child's directory entry before any phase can authorize creation. Repeat this + // for concurrent callers and an existing directory whose first mkdir may not have been synced. + await this.sync(parent, true, current, 0o022); this.check(current) + } + private file(id: string): string { return path.join(this.directory, id + '.json') } + private async read(request: ManagedTerminalRequest, current: () => boolean): Promise { + let handle: nodeFs.promises.FileHandle + try { handle = await fs.open(this.file(request.creationId), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) } + catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw e } + try { + this.check(current) + const stat = await handle.stat(); this.check(current) + if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0 || stat.nlink !== 1 || stat.size > MAX_JOURNAL_BYTES) throw new Error('Unsafe managed creation journal') + const raw = Buffer.alloc(stat.size + 1) + const { bytesRead } = await handle.read(raw, 0, raw.length, 0); this.check(current) + if (bytesRead !== stat.size) throw new Error('Managed creation journal changed') + const saved = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw.subarray(0, bytesRead))) as Journal + if (saved.version !== 1 || saved.creationId !== request.creationId || !/^[0-9a-f]{64}$/.test(saved.fingerprint) || !UUID.test(saved.hostInstance) || + !saved.node || !NODE.test(saved.node.id) || saved.node.kind !== 'terminal' || saved.projectId !== request.projectId || + !['prepared', 'paneCreated', 'registered', 'launchAttempted', 'complete'].includes(saved.phase)) throw new Error('Invalid managed creation journal') + return saved + } finally { await handle.close() } + } + /** Flush each phase and its directory entry before accepting the next terminal side effect. */ + private async sync(file: string, directory: boolean, current: () => boolean, forbiddenMode = 0o077): Promise { + const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | (directory ? constants.O_DIRECTORY : 0)) + try { + this.check(current) + const stat = await handle.stat(); this.check(current) + if ((directory ? !stat.isDirectory() : !stat.isFile()) || stat.uid !== process.getuid?.() || (stat.mode & forbiddenMode) !== 0) throw new Error('Unsafe managed phase barrier') + await handle.sync(); this.check(current) + } finally { await handle.close() } + } + private async write(saved: Journal, current: () => boolean): Promise { + this.check(current) + const raw = JSON.stringify(saved) + if (Buffer.byteLength(raw) > MAX_JOURNAL_BYTES) throw new Error('Managed creation journal too large') + const tmp = `${this.file(saved.creationId)}.${randomUUID()}.tmp` + try { + await fs.writeFile(tmp, raw, { flag: 'wx', mode: 0o600 }) + this.check(current) + await this.sync(tmp, false, current); this.check(current) + renameAtomicSync(tmp, this.file(saved.creationId)) + await this.sync(this.directory, true, current); this.check(current) + } finally { await fs.unlink(tmp).catch(() => {}) } + } + private async revalidate(request: ManagedTerminalRequest, plan: ManagedTerminalPlan, current: () => boolean): Promise { + this.check(current) + const valid = await this.dependencies.revalidate(request, plan); this.check(current) + if (!valid) throw new Error('Managed creation project or launch policy changed') + } + private async createNow(request: ManagedTerminalRequest, hostInstance: string, current: () => boolean): Promise { + this.check(current) + if (!UUID.test(hostInstance) || !this.supported()) throw new Error('Managed creation needs this host\'s enabled POSIX tmux backend') + await this.privateDirectory(current); this.check(current) + const digest = fingerprint(request) + let old: Journal | undefined + try { old = await this.read(request, current); this.check(current) } + catch { throw new ManagedTerminalUncertain('Cannot verify the earlier creation record; inspect this computer before repeating creation') } + if (old) { + if (old.fingerprint !== digest) throw new ManagedTerminalUncertain('Creation id was reused with different choices; inspect the original session', old.node.id) + const complete = this.completed.get(request.creationId) + if (complete?.hostInstance === hostInstance && complete.fingerprint === digest && old.phase === 'complete') { + try { + await this.revalidate(request, complete.plan, current) + const present = await this.store.managedTerminalPresent(request.projectId, complete.plan.node, current); this.check(current) + const valid = present && await this.dependencies.verify(complete.receipt, current); this.check(current) + if (valid && this.store.managedTerminalCurrent(request.projectId, complete.plan.node)) return complete.receipt + } catch { /* lost saved/runtime proof stays uncertain */ } + } + throw new ManagedTerminalUncertain('This creation was already attempted; inspect its recovery session before creating another', old.node.id) + } + const nodeId = `term-${Date.now().toString(36)}-${randomBytes(8).toString('hex')}` + const plan = await this.dependencies.plan(request, nodeId); this.check(current) + if (plan.node.id !== nodeId || plan.node.kind !== 'terminal' || plan.node.ssh || plan.node.sshRemoteTmux || !plan.node.cwd || + plan.options.persistKey !== nodeId || plan.options.ownerProjectId !== request.projectId || plan.options.sshRemote || plan.options.requireRemote || + plan.node.pendingLaunch !== undefined || plan.options.cols !== request.cols || plan.options.rows !== request.rows || + ['cwd', 'shell', 'agentId', 'accountId', 'agentModel'].some((key) => plan.node[key as keyof CanvasNodeState] !== plan.options[key as keyof PtyCreateOptions]) || + plan.node.accountId !== request.accountId || + (request.kind === 'agent' && (!plan.command || plan.node.agentId !== request.agentId)) || + (request.kind === 'shell' && (plan.command !== undefined || plan.node.agentId !== undefined))) throw new Error('Invalid host managed creation plan') + await this.revalidate(request, plan, current) + const node: CanvasNodeState = JSON.parse(JSON.stringify(plan.node)) as CanvasNodeState + if (plan.command) node.pendingLaunch = { command: MANAGED_RECOVERY_COMMAND, after: [], attempted: true, manualOnly: true, executor: 'server' } + const saved: Journal = { version: 1, creationId: request.creationId, fingerprint: digest, hostInstance, node, projectId: request.projectId, phase: 'prepared', ...(plan.command ? { command: plan.command } : {}) } + try { await this.write(saved, current); this.check(current) } + catch { throw new ManagedTerminalUncertain('The creation record was not confirmed; inspect this computer before repeating creation', nodeId) } + let registered = false + try { + saved.receipt = await this.dependencies.create(plan.options, request.creationId, current); this.check(current) + if (saved.receipt.creationId !== request.creationId || saved.receipt.nodeId !== nodeId || saved.receipt.projectId !== request.projectId) throw new Error('Managed pane receipt changed') + saved.phase = 'paneCreated'; await this.write(saved, current); this.check(current) + await this.revalidate(request, plan, current) + registered = await this.store.appendManagedTerminal(request.projectId, node, { current }); this.check(current) + if (!registered) throw new Error('The recovery node could not be saved') + saved.phase = 'registered'; await this.write(saved, current); this.check(current) + if (plan.command) { + await this.revalidate(request, plan, current) + saved.phase = 'launchAttempted'; await this.write(saved, current); this.check(current) + const launchCurrent = (): boolean => current() && this.store.managedTerminalCurrent(request.projectId, node, true) + const present = await this.store.managedTerminalPresent(request.projectId, node, current, true); this.check(current) + if (!present) throw new Error('The saved launch intent changed before submission') + const delivered = await this.dependencies.deliver(saved.receipt, plan.command, launchCurrent); this.check(current) + if (!delivered) throw new Error('Agent launch submission was not confirmed') + const cleared = await this.store.finishManagedTerminal(request.projectId, node, { current }); this.check(current) + if (!cleared) throw new Error('Agent launch recovery intent changed') + delete saved.node.pendingLaunch + } + saved.phase = 'complete'; await this.write(saved, current); this.check(current) + const valid = await this.dependencies.verify(saved.receipt, current); this.check(current) + if (!valid) throw new Error('The created pane was replaced before handoff') + if (!this.store.managedTerminalCurrent(request.projectId, node)) throw new Error('The registered session changed before handoff') + const receipt: ManagedTerminalReceipt = { ...saved.receipt, hostInstance } + this.completed.set(request.creationId, { fingerprint: digest, hostInstance, receipt, plan }) + return receipt + } catch (error) { + // Never kill a guessed name or replay input. A settled failed primitive may have created a + // pane; surface its node with the guarded manual-only intent when this instance still owns it. + if (!registered && current()) { + try { + await this.revalidate(request, plan, current) + await this.store.appendManagedTerminal(request.projectId, node, { current }) + } catch { /* the private phase record remains for explicit operator recovery */ } + } + throw new ManagedTerminalUncertain('Managed creation was not confirmed; inspect the recovery session before repeating creation', nodeId) + } + } +} diff --git a/src/core/native-history-scroll.test.ts b/src/core/native-history-scroll.test.ts new file mode 100644 index 0000000000..e767b554de --- /dev/null +++ b/src/core/native-history-scroll.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { IPty } from 'node-pty' +import { Socket } from 'node:net' +import { NativeWindowsPane } from './native-windows-pane' +import { HostSession } from '../session-host/session' +import { TerminalEmulator } from '../session-host/terminal-emulator' + +const cleanup: Array<() => void> = [] +afterEach(() => { cleanup.splice(0).reverse().forEach((close) => close()); vi.restoreAllMocks() }) +function pane(kind: 'native' | 'session-host') { + const write = vi.fn(), proc = { pid: 42, write, resize: vi.fn(), pause: vi.fn(), resume: vi.fn() } + const size = { cols: 20, rows: 5, scrollback: 100 } + if (kind === 'native') { + const value = new NativeWindowsPane(proc, size) + cleanup.push(() => value.dispose()) + return { value, write, output: (text: string) => value.recordOutput(text), retire: () => value.dispose() } + } + const value = new HostSession('ours', { shell: 'fixture', args: [], cwd: '.', env: {}, ...size }, 100, + { proc: proc as unknown as IPty, term: new TerminalEmulator(size) }) + cleanup.push(() => value.dispose()) + return { value, write, output: (text: string) => value.recordOutput(text), retire: () => { value.retiring = true } } +} + +describe.each(['native', 'session-host'] as const)('%s actual emulator history routing', (kind) => { + it('crosses split mode output, reads retained normal/alternate history, and writes no bytes with mouse off', async () => { + const p = pane(kind) + p.output('older pre-attach line\r\n' + 'retained history\r\n'.repeat(15)) + p.output('\x1b[?1049hcurrent alternate\x1b[?100'); p.output('0l\x1b[?1006l') + const result = await p.value.scrollForHistory(true, 3, true, () => true) + expect(result.status).toBe('history') + if (result.status !== 'history') throw new Error('history expected') + expect(result.capture?.rows.some((row) => row.text.includes('older pre-attach'))).toBe(true) + expect(result.capture?.rows.some((row) => row.section === 'alternate' && row.text.includes('current alternate'))).toBe(true) + expect(await p.value.scrollForHistory(false, 2, false, () => true)).toEqual({ status: 'history' }) + expect(p.write).not.toHaveBeenCalled() + }) + it('uses the application requested default and SGR encodings, then observes a later disabled mode', async () => { + const p = pane(kind) + p.output('\x1b[?100'); p.output('0h\x1b[?1006l') + expect(await p.value.scrollForHistory(true, 2, true, () => true)).toEqual({ status: 'input' }) + expect(p.write.mock.calls).toEqual([['\x1b[M`!!'], ['\x1b[M`!!']]) + p.write.mockClear(); p.output('\x1b[?1006h') + expect(await p.value.scrollForHistory(false, 1, false, () => true)).toEqual({ status: 'input' }) + expect(p.write.mock.calls).toEqual([['\x1b[<65;1;1M']]) + p.write.mockClear(); p.output('\x1b[?1000l') + expect((await p.value.scrollForHistory(true, 1, true, () => true)).status).toBe('history') + expect(p.write).not.toHaveBeenCalled() + }) + it('refuses retirement while real terminal output is queued', async () => { + const p = pane(kind), original = TerminalEmulator.prototype.write + let admitted!: () => void, release!: () => void + const entered = new Promise((done) => { admitted = done }) + const gate = new Promise((done) => { release = done }) + vi.spyOn(TerminalEmulator.prototype, 'write').mockImplementationOnce(async function (this: TerminalEmulator, data) { + admitted(); await gate; return original.call(this, data) + }) + p.output('\x1b[?1000h\x1b[?1006h') + let current = true + const scrolling = p.value.scrollForHistory(true, 2, true, () => current) + await entered; current = false; release() + expect((await scrolling).status).toBe('refused'); expect(p.write).not.toHaveBeenCalled() + }) + it('does not write after plan construction retires the viewer', async () => { + for (const mouse of [false, true]) { + const p = pane(kind); if (mouse) p.output('\x1b[?1000h\x1b[?1006h') + const original = TerminalEmulator.prototype.scrollPlan; let current = true + vi.spyOn(TerminalEmulator.prototype, 'scrollPlan').mockImplementationOnce(function (this: TerminalEmulator, up, lines, capture) { + const result = original.call(this, up, lines, capture); current = false; return result + }) + expect((await p.value.scrollForHistory(true, 2, true, () => current)).status).toBe('refused') + expect(p.write).not.toHaveBeenCalled(); vi.restoreAllMocks() + } + }) + it('reports uncertainty and never repeats remaining wheel bytes after retirement or a possible write', async () => { + for (const failure of ['retire', 'throw'] as const) { + const p = pane(kind); p.output('\x1b[?1000h\x1b[?1006h'); let current = true + p.write.mockImplementationOnce(() => { if (failure === 'throw') throw new Error('write accepted, receipt lost'); current = false }) + expect((await p.value.scrollForHistory(true, 3, false, () => current)).status).toBe('uncertain') + expect(p.write.mock.calls).toEqual([['\x1b[<64;1;1M']]) + } + }) + it('refuses dead panes, malformed distances and unknown emulator state before input', async () => { + const p = pane(kind); p.output('\x1b[?1000h') + for (const lines of [0, 21, 1.5, NaN]) expect((await p.value.scrollForHistory(true, lines, false, () => true)).status).toBe('refused') + vi.spyOn(TerminalEmulator.prototype, 'scrollPlan').mockImplementationOnce(() => { throw new Error('unknown native encoding') }) + expect((await p.value.scrollForHistory(true, 1, false, () => true)).status).toBe('refused') + p.retire(); expect((await p.value.scrollForHistory(true, 1, false, () => true)).status).toBe('refused') + expect(p.write).not.toHaveBeenCalled() + }) +}) + +it('captures actual geometry only after its queued resize, without writing mouse-off input', async () => { + const p = pane('session-host'), value = p.value as HostSession, socket = new Socket() + const prepared = await value.prepareAttachment(socket, 15, 4, false); expect(prepared.commit()).toBe(true) + const resizing = value.resizeFor(socket, 12, 3) + const scrolling = value.scrollForHistory(true, 1, true, () => value.subscribers.has(socket)) + await resizing; const result = await scrolling + expect(result.status).toBe('history') + if (result.status === 'history') expect(result.capture).toMatchObject({ cols: 12, viewportRows: 3 }) + expect(p.write).not.toHaveBeenCalled(); socket.destroy() +}) + +it('does not trust mouse state after a rejected session-host emulator output even though its shared tail heals', async () => { + const p = pane('session-host'), value = p.value as HostSession + vi.spyOn(TerminalEmulator.prototype, 'write').mockRejectedValueOnce(new Error('partial output parse')) + await expect(value.recordOutput('\x1b[?1000h')).rejects.toThrow('partial output') + expect((await value.scrollForHistory(true, 1, true, () => true)).status).toBe('refused') + expect(p.write).not.toHaveBeenCalled() +}) diff --git a/src/core/native-windows-pane.test.ts b/src/core/native-windows-pane.test.ts index 5da3c9e1aa..255963e45a 100644 --- a/src/core/native-windows-pane.test.ts +++ b/src/core/native-windows-pane.test.ts @@ -22,6 +22,19 @@ function fixture(render = true) { afterEach(() => { for (const p of panes.splice(0)) p.dispose() }) describe('native Windows envelope delivery', () => { + it('uses a fresh birth identity for one direct sleeping-shell wake', async () => { + const { pane, write, probe } = fixture() + const shell = { ...expected, command: 'pwsh', argv: ['pwsh'], pids: [10], processBirths: ['shell-birth'] } + probe.mockResolvedValue(shell) + expect(await pane.wakeSleeping('\x1bcodex resume saved\r', shell)).toBe(true) + expect(write.mock.calls).toEqual([['\x1bcodex resume saved\r']]) + write.mockClear() + probe.mockResolvedValue({ ...shell, processBirths: ['replacement-same-pid'] }) + expect(await pane.wakeSleeping('\x1bcodex resume saved\r', shell)).toBe(false) + probe.mockImplementationOnce(async () => { pane.dispose(); return shell }) + expect(await pane.wakeSleeping('\x1bcodex resume saved\r', shell)).toBe(false) + expect(write).not.toHaveBeenCalled() + }) it('waits for split terminal-mode output, pastes one sanitized block, then Enter once it renders', async () => { const { pane, write } = fixture() pane.recordOutput('\x1b[?20') @@ -68,6 +81,41 @@ describe('native Windows envelope delivery', () => { }) }) +describe('native direct PTY explicit composed Send', () => { + it('uses the actual split mode output and keeps full paste separate from Enter', async () => { + const { pane, write, probe } = fixture() + pane.recordOutput('\x1b[?20'); pane.recordOutput('04h') + const times: number[] = [] + write.mockImplementation(() => { times.push(performance.now()) }) + expect(await pane.submitComposed({ kind: 'paste', text: 'one\ntwo Ω\x1b', enter: true }, () => true)).toEqual({ status: 'delivered' }) + expect(write.mock.calls).toEqual([['\x1b[200~one\rtwo Ω\x1b[201~'], ['\r']]) + expect(times[1] - times[0]).toBeGreaterThanOrEqual(150) + expect(probe).not.toHaveBeenCalled() + }) + it('uses one raw Ctrl and never adds framing or Enter even in paste mode', async () => { + const { pane, write } = fixture(); pane.recordOutput('\x1b[?2004h') + expect(await pane.submitComposed({ kind: 'control', text: '\x03', enter: false }, () => true)).toEqual({ status: 'delivered' }) + expect(write.mock.calls).toEqual([['\x03']]) + }) + it('retains uncertainty and sends no Enter after the owning viewer retires or pane is disposed', async () => { + for (const disposed of [false, true]) { + const { pane, write } = fixture(); let current = true + write.mockImplementationOnce(() => { if (disposed) pane.dispose(); else current = false }) + expect((await pane.submitComposed({ kind: 'paste', text: 'one draft', enter: true }, () => current)).status).toBe('uncertain') + expect(write.mock.calls).toEqual([['one draft']]) + } + }) + it('refuses a stale viewer before input and rejects overlapping actions before the first Enter', async () => { + const { pane, write } = fixture() + const input = { kind: 'paste', text: 'first', enter: true } as const + expect((await pane.submitComposed(input, () => false)).status).toBe('refused') + const first = pane.submitComposed(input, () => true) + expect((await pane.submitComposed({ ...input, text: 'second' }, () => true)).status).toBe('refused') + expect((await first).status).toBe('delivered') + expect(write.mock.calls).toEqual([['first'], ['\r']]) + }) +}) + describe('native Windows sendText (the write verb and the app’s own writers)', () => { it('reports folded pasted text without submitting or retrying', async () => { const write = vi.fn() diff --git a/src/core/native-windows-pane.ts b/src/core/native-windows-pane.ts index 83fd40db7d..cc519e9f76 100644 --- a/src/core/native-windows-pane.ts +++ b/src/core/native-windows-pane.ts @@ -6,6 +6,10 @@ import { sendTextWhenSettled } from './settled-text' import type { PaneOwner } from '../shared/agents/pane-owner-predicate' import { sanitizePasteText } from './paste-injection' import { pasteThenSubmitWhenSettled, type SettleOptions } from './settled-submit' +import { validSleepingWakeInput } from '../session-host/sleeping-wake' +import { ComposedPty, waitForComposedEnter } from './composed-pty' +import { COMPOSED_INPUT_UNCERTAIN, type ComposedInput, type ComposedInputResult } from '../shared/composed-input' +import type { NativeScrollResult } from '../shared/history-scroll' export { sameNativeProcess } from '../session-host/windows-pane-owner' @@ -17,6 +21,10 @@ export class NativeWindowsPane { private readonly screen: TerminalEmulator private tail: Promise = Promise.resolve() private alive = true + private readonly composed = new ComposedPty({ + bracketed: () => this.pasteAware(), + write: (data) => this.proc.write(data) + }) constructor( private readonly proc: { pid: number; write(data: string): void }, @@ -41,6 +49,37 @@ export class NativeWindowsPane { return this.alive ? this.screen.serialize(full ? undefined : 200) : '' } + async historyText(): Promise { + await this.tail + if (!this.alive) throw new Error('This terminal has exited.') + return this.screen.historyText() + } + + /** Decide behind actual output/geometry, then write only to this still-owned native pane. */ + scrollForHistory(up: boolean, lines: number, capture: boolean, current: () => boolean): Promise { + const refused = (): NativeScrollResult => ({ status: 'refused', message: 'This terminal viewer is no longer current or its mouse state is unavailable.' }) + const uncertain = (): NativeScrollResult => ({ status: 'uncertain', message: 'Wheel input may have reached the terminal. It was not sent again.' }) + const valid = (): boolean => this.alive && current() + const scrolling = this.tail.then((): NativeScrollResult => { + if (!valid()) return refused() + let attempted = false + try { + const plan = this.screen.scrollPlan(up, lines, capture) + if (!valid()) return refused() + if (plan.status !== 'wheel') return plan + for (const data of plan.data) { + if (!valid()) return attempted ? uncertain() : refused() + attempted = true + this.proc.write(data) + } + return valid() ? { status: 'input' } : uncertain() + } catch { return attempted ? uncertain() : refused() } + }, () => { this.alive = false; return refused() }) + // Later output and geometry follow this turn; failed wheel writes do not poison raw input. + this.tail = scrolling.then(() => {}, () => { this.alive = false }) + return scrolling + } + async owner(): Promise { if (!this.alive) return null const owner = await this.probe(this.proc.pid, this.generation) @@ -52,6 +91,15 @@ export class NativeWindowsPane { return this.alive && this.screen.bracketedPasteRequested() } + async wakeSleeping(data: string, expected: PaneOwner): Promise { + if (!this.alive || !validSleepingWakeInput(data)) return false + if (!sameNativeProcess(expected, await this.owner()) || !this.alive) return false + try { + this.proc.write(data) + return true + } catch { return false } + } + async sendEnvelope(envelope: string, expected?: PaneOwner): Promise { if (!envelope || !expected || !(await this.pasteAware())) return false // Re-check the exact process immediately before typing. A replacement with the same @@ -96,8 +144,24 @@ export class NativeWindowsPane { }, this.settle) } + /** Explicit phone Send, distinct from the observed-screen agent-message delivery above. */ + async submitComposed(input: ComposedInput, current: () => boolean): Promise { + const valid = (): boolean => this.alive && current() + const prepared = this.composed.prepare(this, input, valid) + if (prepared.status !== 'prepared') return prepared + try { + const result = await this.composed.write(this, prepared.ticket, 'paste', valid) + if (result.status !== 'awaiting-enter') return result.status === 'delivered' && !valid() + ? { status: 'uncertain', message: COMPOSED_INPUT_UNCERTAIN } : result + await waitForComposedEnter() + const entered = await this.composed.write(this, prepared.ticket, 'enter', valid) + return entered.status === 'delivered' && valid() ? entered : { status: 'uncertain', message: COMPOSED_INPUT_UNCERTAIN } + } finally { this.composed.cancel(this, prepared.ticket) } + } + dispose(): void { this.alive = false + this.composed.dispose() void this.tail.finally(() => this.screen.dispose()) } } diff --git a/src/core/phone-approval.test.ts b/src/core/phone-approval.test.ts index c2434d64eb..3604f98f01 100644 --- a/src/core/phone-approval.test.ts +++ b/src/core/phone-approval.test.ts @@ -44,6 +44,17 @@ describe('bounded handshake consent', () => { expect(await approvals.approve({ id: stopped, pub: 'phone' })).toEqual({ status: 'stale' }) expect(persist).not.toHaveBeenCalled() }) + it('forgets every pending consent of a revoked key, and only that key', async () => { + const { approvals, persist, cleared } = setup() + const revoked = approvals.add('revoked') + const other = approvals.add('other') + approvals.forget('revoked') + expect(cleared).toHaveBeenCalledExactlyOnceWith(revoked) + expect(await approvals.approve({ id: revoked, pub: 'revoked' })).toEqual({ status: 'stale' }) + expect(persist).not.toHaveBeenCalled() + expect(await approvals.approve({ id: other, pub: 'other' })).toEqual({ status: 'persisted' }) + expect(persist).toHaveBeenCalledExactlyOnceWith('other') + }) it('bounds pending records from many distinct devices', async () => { const { approvals, cleared } = setup() const first = approvals.add('first') diff --git a/src/core/phone-approval.ts b/src/core/phone-approval.ts index 921d106da3..d068aa3870 100644 --- a/src/core/phone-approval.ts +++ b/src/core/phone-approval.ts @@ -65,6 +65,11 @@ export function createPhoneApprovals(deps: { return true }, clear, + /** Withdraw every pending consent for `pub`: its device was revoked, and a dialog raised before + * the revoke must not be able to pin the forgotten key again. */ + forget(pub: string): void { + for (const [id, p] of pending) if (p.pub === pub) clear(id) + }, /** Drop every pending consent whose key satisfies `match` (a revoke: an open SAS dialog for a * revoked key must not be able to re-pin it afterwards). */ clearWhere(match: (pub: string) => boolean): void { diff --git a/src/core/projects-list-blob.test.ts b/src/core/projects-list-blob.test.ts new file mode 100644 index 0000000000..c0ea10539f --- /dev/null +++ b/src/core/projects-list-blob.test.ts @@ -0,0 +1,122 @@ +// The `projects.list` blob (core/projects-list-blob.ts): the one assembly the desktop's +// `listProjectsOutput` and the Android interop fixture share (audit A64). These pin its contract on +// the desktop side, where every CI run sees it; the Android relay interop test parses what the same +// function produces with the Kotlin client. +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, promises as fs, rmSync } from 'fs' +import os from 'os' +import path from 'path' +import { initPlatform, resetPlatformForTests } from './platform' +import { fakePlatform } from './platform-fake' +import { WorkspaceStore } from './workspace-store' +import { + AGENT_STATUS_FILE, + _resetForTest as resetMirror, + flush as flushMirror, + initAgentStatusMirror, + recordAgentEvent +} from './agent-status-mirror' +import { buildProjectsListBlob, PROJECTS_SPLIT_MARK, STATUS_SPLIT_MARK } from './projects-list-blob' +import type { Workspace } from '../shared/types' + +let userData = '' + +beforeEach(() => { + userData = mkdtempSync(path.join(os.tmpdir(), 'nt-projects-blob-')) + resetPlatformForTests() + initPlatform(fakePlatform({ userDataDir: userData })) + resetMirror() +}) + +afterEach(() => { + resetMirror() + resetPlatformForTests() + rmSync(userData, { recursive: true, force: true }) +}) + +const EMPTY: Workspace = { version: 2, activeProjectId: '', projects: [] } + +/** Split exactly the way both phone clients do: on the marker lines. */ +function sections(blob: string): { workspace: string; sessions: string; status: string } { + const [workspace, rest] = blob.split(`\n${PROJECTS_SPLIT_MARK}\n`) + const [sessions, status] = rest.split(`\n${STATUS_SPLIT_MARK}\n`) + return { workspace, sessions, status } +} + +describe('buildProjectsListBlob', () => { + it('lays out workspace, session names and the mirror file between the two markers the phones split on', async () => { + await fs.writeFile(path.join(userData, AGENT_STATUS_FILE), '{"v":1}') + const blob = await buildProjectsListBlob({ + workspace: { load: async () => EMPTY }, + userDataDir: userData, + listSessions: async () => ['nt-a', 'nt-b'] + }) + // The markers are wire contract (NodetermProjects.swift, ProjectsParser.kt): pinned verbatim. + expect(PROJECTS_SPLIT_MARK).toBe('--NT-PROJECTS-SPLIT--') + expect(STATUS_SPLIT_MARK).toBe('--NT-STATUS-SPLIT--') + expect(blob).toBe(`${JSON.stringify(EMPTY)}\n--NT-PROJECTS-SPLIT--\nnt-a\nnt-b\n--NT-STATUS-SPLIT--\n{"v":1}`) + }) + + it('loads the workspace read-only, so a listing mid git-merge never sidelines a project file', async () => { + const seen: Array<{ sideline: boolean }> = [] + await buildProjectsListBlob({ + workspace: { + load: async (opts) => { + seen.push(opts) + return EMPTY + } + }, + userDataDir: userData, + listSessions: async () => [] + }) + expect(seen).toEqual([{ sideline: false }]) + }) + + it('degrades each failed read to an empty section and never rejects', async () => { + const blob = await buildProjectsListBlob({ + workspace: { load: () => Promise.reject(new Error('index unreadable')) }, + userDataDir: path.join(userData, 'missing'), + listSessions: () => Promise.reject(new Error('no tmux')) + }) + expect(blob).toBe(`\n${PROJECTS_SPLIT_MARK}\n\n${STATUS_SPLIT_MARK}\n`) + }) + + it('serves the ASSEMBLED v2 workspace from a v3 index, and the file the mirror itself wrote', async () => { + // The real store: a folder ref's nodes live only in its .nodeterm/project.json, never in the index. + const repo = path.join(userData, 'repo') + await new WorkspaceStore().save({ + version: 2, + activeProjectId: 'p1', + projects: [ + { + id: 'p1', + name: 'Demo', + color: '#0a84ff', + cwd: repo, + viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [ + { id: 'term-1', kind: 'terminal', title: 'T', color: '#000', group: null, position: { x: 0, y: 0 }, size: { width: 1, height: 1 } } + ] + } + ] + }) + expect(JSON.parse(await fs.readFile(path.join(userData, 'workspace.json'), 'utf8')).version).toBe(3) + // The real mirror, at its default path: writer and reader must agree on the file. + initAgentStatusMirror() + recordAgentEvent({ nodeId: 'term-1', agentId: 'claude', kind: 'state', state: 'working', sessionId: 's-1' }) + await flushMirror() + + const blob = await buildProjectsListBlob({ + workspace: new WorkspaceStore(), + userDataDir: userData, + listSessions: async () => ['nt-term-1'] + }) + const s = sections(blob) + const workspace = JSON.parse(s.workspace) as Workspace + expect(workspace.version).toBe(2) + expect(workspace.projects[0]).toMatchObject({ id: 'p1', name: 'Demo', cwd: repo }) + expect(workspace.projects[0].nodes.map((n) => n.id)).toEqual(['term-1']) + expect(s.sessions).toBe('nt-term-1') + expect(JSON.parse(s.status).nodes['term-1']).toMatchObject({ state: 'working', agentId: 'claude', sessionId: 's-1' }) + }) +}) diff --git a/src/core/projects-list-blob.ts b/src/core/projects-list-blob.ts new file mode 100644 index 0000000000..9e4f48d11d --- /dev/null +++ b/src/core/projects-list-blob.ts @@ -0,0 +1,51 @@ +import { promises as fs } from 'fs' +import path from 'path' +import type { Workspace } from '../shared/types' +import { AGENT_STATUS_FILE } from './agent-status-mirror' + +/** + * The `projects.list` blob the standing phone host serves over the relay (host-service.ts), and the + * ONE definition of how it is assembled: the desktop's `listProjectsOutput` (src/main/index.ts) and + * the Android interop fixture (android/protocol/src/test/interop/host-fixture.ts) both call + * `buildProjectsListBlob`, so the Kotlin client is tested against the bytes the desktop serves, not + * against a hand-written copy of them (audit A64). + * + * Layout: `\n--NT-PROJECTS-SPLIT--\n\n + * --NT-STATUS-SPLIT--\n` — the same three sections, in the same order, that a phone + * on direct SSH reads off the host itself (`workspace.json`, `tmux ls`, `agent-status.json`). Both + * phone clients split on the markers: keep them in sync with NodetermProjects.swift (iOS, + * nodeterm-ios) and android/protocol/src/main/kotlin/dev/nodeterm/protocol/model/ProjectsParser.kt. + */ +export const PROJECTS_SPLIT_MARK = '--NT-PROJECTS-SPLIT--' +export const STATUS_SPLIT_MARK = '--NT-STATUS-SPLIT--' + +export interface ProjectsListSources { + /** The machine's workspace store (`WorkspaceStore`). Loaded read-only; see `buildProjectsListBlob`. */ + workspace: { load(opts: { sideline: boolean }): Promise } + /** userData: where the agent-status mirror writes `AGENT_STATUS_FILE`. */ + userDataDir: string + /** The live nodeterm tmux session names (`nt-`). */ + listSessions(): Promise +} + +/** + * Build the blob. Reads the same things the SSH browse path reads on the host, without SSH. Every + * read is best-effort (a failed one degrades to an empty section), so this never rejects. + * + * The workspace section is the ASSEMBLED v2-shaped workspace, never the raw workspace.json: since the + * v3 migration that file is an index whose folder refs hold no node data at all, and the phones decode + * `{ projects: [Project] }`, so a raw v3 file would list zero projects. `load()` re-reads each ref's + * `.nodeterm/project.json` and returns `{version: 2, projects: […]}`; it is idempotent, so calling it + * per request is safe. It runs with `sideline: false`: a phone listing projects in the middle of a git + * merge must not move a conflict-marked project.json aside to `.corrupt-` (sidelining is for the + * boot and renderer loads only). + */ +export async function buildProjectsListBlob(src: ProjectsListSources): Promise { + const workspace = await src.workspace + .load({ sideline: false }) + .then((w) => JSON.stringify(w)) + .catch(() => '') + const status = await fs.readFile(path.join(src.userDataDir, AGENT_STATUS_FILE), 'utf8').catch(() => '') + const sessions = (await src.listSessions().catch(() => [])).join('\n') + return `${workspace}\n${PROJECTS_SPLIT_MARK}\n${sessions}\n${STATUS_SPLIT_MARK}\n${status}` +} diff --git a/src/core/pty-background-write-over.test.ts b/src/core/pty-background-write-over.test.ts new file mode 100644 index 0000000000..1797764b52 --- /dev/null +++ b/src/core/pty-background-write-over.test.ts @@ -0,0 +1,113 @@ +// `PtyManager.backgroundWriteOver` — the relay phone's quick answer (`node.sendKeys`) for a node of +// an SSH project, typed into that node's REMOTE tmux pane over the project's ControlMaster. +// +// The line itself is run against a real tmux in `remote-send-keys.realtmux.test.ts`. What is left +// for this file is DISPATCH and the failure contract: exactly one ssh exec carrying the builder's +// line, nothing ever aimed at this machine's tmux, and `false` — never a throw, never a retry — for +// every way it can fail. +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +vi.mock('./native-windows-pane', () => ({ NativeWindowsPane: class {} })) +import { initPlatform, resetPlatformForTests } from './platform' +import { fakePlatform } from './platform-fake' +import { sessionName } from './tmux-naming' +import { remoteTmuxSendKeysArgs } from './remote-ssh/control-master' + +/** Every `runAsync` in pty-manager lands here. `fail` makes the call reject like a non-zero exit. */ +const calls: Array<{ file: string; args: string[]; opts: unknown }> = [] +const script = vi.hoisted(() => ({ fail: false })) + +vi.mock('child_process', () => { + type Cb = (err: Error | null, res?: { stdout: string; stderr: string }) => void + const execFile = (file: string, args: string[], a?: unknown, b?: unknown): unknown => { + const cb = (typeof a === 'function' ? a : b) as Cb | undefined + calls.push({ file, args, opts: typeof a === 'function' ? undefined : a }) + if (script.fail) cb?.(Object.assign(new Error("can't find session"), { code: 1 })) + else cb?.(null, { stdout: '', stderr: '' }) + return {} + } + return { execFile, execFileSync: (): string => '' } +}) + +const ssh = vi.hoisted(() => ({ path: '/usr/bin/ssh' as string | null })) +vi.mock('./exec-path', async (importOriginal) => ({ + ...(await importOriginal()), + findExecutableSync: (bin: string) => (bin === 'ssh' ? ssh.path : null), + shellPathNow: () => '/usr/bin:/bin', + resolveShellPath: async () => '/usr/bin:/bin' +})) + +vi.mock('node-pty', () => ({ + spawn: () => { + throw new Error('backgroundWriteOver must never spawn a pty') + } +})) + +const NODE = 'node-r' +const SSH_REMOTE = { + controlPath: '/tmp/cm-abc', + conn: { host: 'box', user: 'me' }, + remoteCwd: '~/repo' +} + +/** A manager that has never opened the node — the relay case: the SSH node is not mounted here. */ +async function manager() { + const { PtyManager } = await import('./pty-manager') + const mgr = new PtyManager() as unknown as { + tmuxPath: string | null + backgroundWriteOver(k: string, d: string, r: typeof SSH_REMOTE): Promise + } + mgr.tmuxPath = '/usr/bin/tmux' + return mgr +} + +describe('PtyManager.backgroundWriteOver', () => { + beforeEach(() => { + calls.length = 0 + ssh.path = '/usr/bin/ssh' + script.fail = false + vi.resetModules() + initPlatform(fakePlatform()) + }) + afterEach(() => { + resetPlatformForTests() + }) + + it('runs ONE ssh exec over the master carrying the remote send-keys line — no session needed', async () => { + const mgr = await manager() + expect(await mgr.backgroundWriteOver(NODE, '1', SSH_REMOTE)).toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0].file).toBe('/usr/bin/ssh') + expect(calls[0].args).toEqual( + remoteTmuxSendKeysArgs(SSH_REMOTE.conn, SSH_REMOTE.controlPath, sessionName(NODE), '1') + ) + // Bounded like every probe an interaction waits on: a half-dead master must not hang the phone. + expect((calls[0].opts as { timeout?: number }).timeout).toBe(6_000) + }) + + it('never aims anything at this machine’s tmux', async () => { + const mgr = await manager() + await mgr.backgroundWriteOver(NODE, '\u001b', SSH_REMOTE) + expect(calls.every((c) => c.file === '/usr/bin/ssh')).toBe(true) + }) + + it('a failed exec (no such session on the host, master gone) is false — and is not retried', async () => { + script.fail = true + const mgr = await manager() + expect(await mgr.backgroundWriteOver(NODE, '1', SSH_REMOTE)).toBe(false) + expect(calls).toHaveLength(1) + }) + + it('is false, with nothing run, without an ssh binary', async () => { + ssh.path = null + const mgr = await manager() + expect(await mgr.backgroundWriteOver(NODE, '1', SSH_REMOTE)).toBe(false) + expect(calls).toHaveLength(0) + }) + + it('is false, with nothing run, for no keys or a node id that names no session', async () => { + const mgr = await manager() + expect(await mgr.backgroundWriteOver(NODE, '', SSH_REMOTE)).toBe(false) + expect(await mgr.backgroundWriteOver('', '1', SSH_REMOTE)).toBe(false) + expect(calls).toHaveLength(0) + }) +}) diff --git a/src/core/pty-background-write.test.ts b/src/core/pty-background-write.test.ts index 819f079741..4d3842e7a4 100644 --- a/src/core/pty-background-write.test.ts +++ b/src/core/pty-background-write.test.ts @@ -7,6 +7,8 @@ import { fakePlatform, type FakePlatform } from './platform-fake' import { IPC } from '../shared/ipc' import { DEFAULT_SETTINGS } from '../shared/types' import { TMUX_SOCKET, sessionName, isSessionName } from './tmux-naming' +import type { PaneOwner } from '../shared/agents/pane-owner-predicate' +import type { SleepingWakeRequest } from '../shared/agents/sleeping-wake' import type { ControlSpawn } from './tmux-control-client' /** @@ -32,6 +34,10 @@ const spawned: FakePty[] = [] /** Spawn/dispose events across BOTH child kinds, so a test can assert their relative ORDER. */ const log: string[] = [] +// These delivery tests exercise tmux and plain PTYs. The Windows screen emulator has its own +// native-adapter suites and is unrelated to the commands/bytes judged here. +vi.mock('./native-windows-pane', () => ({ NativeWindowsPane: class {} })) + vi.mock('node-pty', () => ({ spawn: () => { const p: FakePty = { writes: [], killed: false, throwOnWrite: false } @@ -61,18 +67,47 @@ vi.mock('node-pty', () => ({ /** Every tmux side-call goes through child_process; `liveTmuxSessions` answers `has-session`. */ const liveTmuxSessions = new Set() +const tmuxCalls: string[][] = [] +let tmuxWriteFails = false +let tmuxCopyMode = false +let tmuxBytes = '' +let wakeOwnerReply = '41 bash' +let remoteOwnerOutput = '' +const paneIds = new Map() +const paneId = (target: string): string => { + const id = paneIds.get(target) ?? `%${paneIds.size + 1}` + paneIds.set(target, id) + return id +} vi.mock('child_process', () => { - type Cb = (err: Error | null, res?: { stdout: string; stderr: string }) => void + type Cb = (err: Error | null, res?: string | { stdout: string; stderr: string }, stderr?: string) => void const execFile = (file: string, args: string[], a?: unknown, b?: unknown): unknown => { const cb = (typeof a === 'function' ? a : b) as Cb | undefined const ok = (stdout: string): void => cb?.(null, { stdout, stderr: '' }) - if (args.includes('has-session')) { + if (file.endsWith('ssh') && remoteOwnerOutput && args.some((arg) => arg.includes('##NTPANE '))) { + ok(remoteOwnerOutput) + } else if (args.includes('display-message') && args.includes('#{pane_pid} #{pane_current_command}')) { + tmuxCalls.push(args); ok(wakeOwnerReply + '\n') + } else if (args.includes('display-message') && args.includes('#{pane_id} #{pane_in_mode}')) { + tmuxCalls.push(args) + const target = args[args.indexOf('-t') + 1].replace(/^=/, '').replace(/:$/, '') + ok(`${paneId(target)} ${tmuxCopyMode ? '1' : '0'}\n`) + } else if (args.includes('send-keys')) { + tmuxCalls.push(args) + if (tmuxWriteFails) cb?.(new Error('no such pane')) + else { + if (args.includes('-X')) tmuxCopyMode = false + else if (!tmuxCopyMode) + tmuxBytes += Buffer.from(args.slice(args.indexOf('-H') + 1).join(''), 'hex').toString('utf8') + ok('') + } + } else if (args.includes('has-session')) { const target = args[args.indexOf('-t') + 1] if (liveTmuxSessions.has(target)) ok('') else cb?.(Object.assign(new Error('no such session'), { code: 1 })) } else if (args[0] === '-ilc') { - ok('__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__') + cb?.(null, '__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__', '') } else { ok('') } @@ -99,6 +134,14 @@ class FakeControlSpawn implements ControlSpawn { calls: Array<{ bin: string; args: string[] }> = [] children: FakeControlChild[] = [] autoReply = true + /** A tiny tmux model: judge bytes delivered to the app, rather than the command's spelling. */ + panes: Map | undefined + failCancel = false + modeReply: string | undefined + afterProbe: (() => void) | undefined + holdCancel = false + heldReplies: Array<() => void> = [] + private identities = new Map() private num = 0 spawn(bin: string, args: string[]) { this.calls.push({ bin, args }) @@ -112,6 +155,8 @@ class FakeControlSpawn implements ControlSpawn { feed: (s) => onData?.(Buffer.from(s, 'latin1')) } this.children.push(child) + // argv's attach-session answers too, before any command subsequently written on stdin. + void Promise.resolve().then(() => child.feed('%begin 1700 0 0\n%end 1700 0 0\n')) return { stdin: { write: (s: string) => { @@ -119,8 +164,13 @@ class FakeControlSpawn implements ControlSpawn { // `detach-client` is the disposal handshake, not a command with a reply. if (!this.autoReply || s.startsWith('detach-client')) return const n = ++this.num + const reply = this.reply(s) // A real microtask (not a timer): tests run under fake timers. - void Promise.resolve().then(() => child.feed(`%begin 1700 ${n} 0\n%end 1700 ${n} 0\n`)) + const respond = () => child.feed( + `%begin 1700 ${n} 0\n${reply.body}${reply.ok ? '%end' : '%error'} 1700 ${n} 0\n` + ) + if (this.holdCancel && s.includes('-X cancel')) this.heldReplies.push(respond) + else void Promise.resolve().then(respond) } }, stdout: { @@ -140,13 +190,42 @@ class FakeControlSpawn implements ControlSpawn { get only(): FakeControlChild { return this.children[0] } + private reply(line: string): { ok: boolean; body: string } { + const args = line.trim().split(' ') + if (args[0] !== 'display-message' && args[0] !== 'send-keys') return { ok: true, body: '' } + const target = args[args.indexOf('-t') + 1] + const name = target?.replace(/^=/, '').replace(/:$/, '') + const resolved = this.panes && (target.startsWith('=') + ? name + : [...this.panes.keys()].find((key) => key.startsWith(name))) + const pane = target.startsWith('%') + ? this.identities.get(target) + : resolved ? this.panes?.get(resolved) : undefined + if (this.panes && !pane) return { ok: false, body: 'no such pane\n' } + if (args[0] === 'display-message' && line.includes('#{pane_pid} #{pane_current_command}')) return { ok: true, body: wakeOwnerReply + '\n' } + if (args[0] === 'display-message') { + const id = paneId(name) + if (pane) this.identities.set(id, pane) + const body = this.modeReply ?? `${id} ${pane?.copyMode ? '1' : '0'}\n` + this.afterProbe?.() + return { ok: true, body } + } + if (args[0] === 'send-keys' && args.includes('-X')) { + if (this.failCancel) return { ok: false, body: 'cancel failed\n' } + if (pane) pane.copyMode = false + } else if (args[0] === 'send-keys' && pane && !pane.copyMode) { + pane.bytes += Buffer.from(args.slice(args.indexOf('-H') + 1).join(''), 'hex').toString('utf8') + } + return { ok: true, body: '' } + } } const ALICE = 1 const BOB = 2 /** `ls\n` as the wire sees it: hex bytes, because a control-mode command is one text line. */ -const LS_KEYS = (target: string): string => `send-keys -t ${target} -H 6c 73 0a\n` +const LS_KEYS = (target: string): string => `send-keys -t ${paneId(target)} -H 6c 73 0a\n` +const MODE = (target: string): string => `display-message -p -t =${target}: '#{pane_id} #{pane_in_mode}'\n` /** * A machine with pty devices to spare, always. @@ -172,6 +251,13 @@ describe('background writes into released sessions', () => { spawned.length = 0 log.length = 0 liveTmuxSessions.clear() + tmuxCalls.length = 0 + tmuxWriteFails = false + tmuxCopyMode = false + tmuxBytes = '' + wakeOwnerReply = '41 bash' + remoteOwnerOutput = '' + paneIds.clear() control = new FakeControlSpawn() userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-bgwrite-')) fake = fakePlatform({ userDataDir }) @@ -229,6 +315,63 @@ describe('background writes into released sessions', () => { sessionName(key) ] + const sleepingOwner: PaneOwner = { panePid: 41, paneId: '%1', command: 'bash', tty: '/dev/pts/1', argv: ['bash -i'], pids: [41] } + const sleepingRequest: SleepingWakeRequest = { nodeId: 'node-1', agentId: 'codex', command: 'codex resume s --ask-for-approval on-request', exitedByUs: true, + recorded: { panePid: 41, paneId: '%1', command: 'bash' } } + + it('wakes a live and a released owned pane through the actual manager without spawning a view', async () => { + const m = await tmuxManager() + vi.spyOn(m, 'paneOwner').mockResolvedValue(sleepingOwner) + const { sessionId } = await create(ALICE, 'node-1') + expect(await fake.handlers[IPC.ptyWakeSleeping](sleepingRequest)).toEqual({ delivered: true, verdict: 'resume' }) + expect(tmuxBytes).toBe('\x15' + sleepingRequest.command + '\r') + kill(ALICE, sessionId) + tmuxBytes = '' + expect((await m.wakeSleeping(sleepingRequest)).delivered).toBe(true) + expect(control.only.writes.some((line) => line.includes('#{pane_pid} #{pane_current_command}'))).toBe(true) + expect(spawned).toHaveLength(1) + }) + + it('refuses an unowned or a replaced generation before sending a wake', async () => { + const m = await tmuxManager() + const probe = vi.spyOn(m, 'paneOwner').mockResolvedValue(sleepingOwner) + expect((await m.wakeSleeping(sleepingRequest)).delivered).toBe(false) + expect(probe).not.toHaveBeenCalled() + await create(ALICE, 'node-1') + probe.mockImplementationOnce(async () => { + await m.destroySession(ALICE, 'node-1') + return sleepingOwner + }) + expect((await m.wakeSleeping(sleepingRequest)).verdict).toBe('context-changed') + expect(tmuxBytes).toBe('') + }) + + it('pins the final tmux delivery to the verified pane pid and foreground before clearing any line', async () => { + const m = await tmuxManager() + await create(ALICE, 'node-1') + vi.spyOn(m, 'paneOwner').mockResolvedValue(sleepingOwner) + for (const stale of ['42 bash', '41 node']) { + wakeOwnerReply = stale + expect((await m.wakeSleeping(sleepingRequest)).delivered).toBe(false) + expect(tmuxBytes).toBe('') + } + }) + + it('wakes a released SSH project only over its retained actual master and remote pane proof', async () => { + const m = await tmuxManager() + const remote = { conn: { host: 'h1', user: 'u' }, controlPath: '/tmp/cm-owned', remoteCwd: '/srv/app' } + const created = await fake.handlers[IPC.ptyCreate](ALICE, { cols: 80, rows: 24, persistKey: 'node-r', sshRemote: remote }) as { sessionId: string } + kill(ALICE, created.sessionId) + remoteOwnerOutput = '##NTPANE 41|/dev/pts/1|bash|%1\n41 41 S+ bash -i\n' + const writer = vi.spyOn(m, 'backgroundWriteOver').mockResolvedValue(true) + const localProbe = vi.spyOn(m, 'paneOwner') + expect((await m.wakeSleeping({ ...sleepingRequest, nodeId: 'node-r' })).delivered).toBe(true) + expect(writer).toHaveBeenCalledWith('node-r', '\x15' + sleepingRequest.command + '\r', remote, expect.objectContaining({ panePid: 41, paneId: '%1', command: 'bash' })) + expect(localProbe).not.toHaveBeenCalled() + expect(tmuxCalls).toEqual([]) + expect(spawned).toHaveLength(1) + }) + it('types into a released session over a control client — no pty, exact send-keys line', async () => { const m = await tmuxManager() await release(ALICE, 'node-1') @@ -242,24 +385,189 @@ describe('background writes into released sessions', () => { expect(spawned[0].killed).toBe(true) }) - it('writes straight into the painter while the node is on screen — control clients untouched', async () => { + it('writes to the live tmux pane without passing quick answers through its painter', async () => { const m = await tmuxManager() await create(ALICE, 'node-1') expect(await m.backgroundWrite('node-1', 'ls\n')).toBe(true) - expect(spawned[0].writes).toEqual(['ls\n']) + expect(spawned[0].writes).toEqual([]) + expect(tmuxCalls).toEqual([ + ['-L', TMUX_SOCKET, 'display-message', '-p', '-t', '=nt-node-1:', '#{pane_id} #{pane_in_mode}'], + ['-L', TMUX_SOCKET, 'send-keys', '-t', '%1', '-H', '6c', '73', '0a'] + ]) expect(control.calls).toHaveLength(0) }) - it('reports a failure rather than rejecting when the painter’s pty is already gone', async () => { + it('reports a failure rather than typing through the painter when the tmux pane is gone', async () => { const m = await tmuxManager() await create(ALICE, 'node-1') - spawned[0].throwOnWrite = true // node-pty throws on a write to a process that has exited + tmuxWriteFails = true // `write()` fires this path and forgets it, so a rejection here would surface as an UNHANDLED - // rejection in the main process — a crash risk, from a pty that merely died first. + // rejection in the main process. A pane disappearing during delivery is a normal refusal. await expect(m.backgroundWrite('node-1', 'ls\n')).resolves.toBe(false) + expect(spawned[0].writes).toEqual([]) + }) + + it('cancels copy mode before delivering a quick answer to a live tmux pane', async () => { + const m = await tmuxManager() + await create(ALICE, 'node-1') + tmuxCopyMode = true + + expect(await m.backgroundWrite('node-1', '2')).toBe(true) + + expect(tmuxBytes).toBe('2') + expect(tmuxCopyMode).toBe(false) + expect(spawned[0].writes).toEqual([]) + expect(control.calls).toHaveLength(0) + }) + + it('writes directly into a plain pty when tmux is disabled', async () => { + const m = await managerWith({ tmuxEnabled: false }) + await create(ALICE, 'node-1') + + expect(await m.backgroundWrite('node-1', '2')).toBe(true) + expect(spawned[0].writes).toEqual(['2']) + expect(tmuxCalls).toEqual([]) + spawned[0].throwOnWrite = true + await expect(m.backgroundWrite('node-1', '2')).resolves.toBe(false) + }) + + it('answers a live SSH-project pane through its master rather than its attached client', async () => { + const m = await tmuxManager() + const remote = { + conn: { host: 'h1', user: 'u' }, controlPath: '/tmp/cm', remoteCwd: '/srv/app' + } + await fake.handlers[IPC.ptyCreate](ALICE, { + cols: 80, rows: 24, persistKey: 'node-r', sshRemote: remote + }) + const writeOver = vi.spyOn(m, 'backgroundWriteOver').mockResolvedValue(true) + + expect(await m.backgroundWrite('node-r', '2')).toBe(true) + expect(writeOver).toHaveBeenCalledExactlyOnceWith('node-r', '2', remote) + expect(spawned[0].writes).toEqual([]) + expect(tmuxCalls).toEqual([]) + }) + + it('a missing released pane does not prefix-match a neighbouring node and report success', async () => { + const m = await tmuxManager() + await release(ALICE, 'node-1') + const neighbour = { copyMode: false, bytes: '' } + control.panes = new Map([['nt-node-10', neighbour]]) + + expect(await m.backgroundWrite('node-1', '2')).toBe(false) + expect(neighbour.bytes).toBe('') + }) + + it('a released pane leaves copy mode before answering, with one reply per command', async () => { + const m = await tmuxManager() + await release(ALICE, 'node-1') + const pane = { copyMode: true, bytes: '' } + control.panes = new Map([['nt-node-1', pane]]) + + expect(await m.backgroundWrite('node-1', '2')).toBe(true) + expect(await m.backgroundWrite('node-1', '\u001b')).toBe(true) + + expect(pane).toEqual({ copyMode: false, bytes: '2\u001b' }) + expect(control.only.writes).toEqual([ + MODE('nt-node-1'), + 'send-keys -t %1 -X cancel\n', + 'send-keys -t %1 -H 32\n', + MODE('nt-node-1'), + 'send-keys -t %1 -H 1b\n' + ]) + }) + + it('does not send an answer after copy-mode cancellation failed', async () => { + const m = await tmuxManager() + await release(ALICE, 'node-1') + const pane = { copyMode: true, bytes: '' } + control.panes = new Map([['nt-node-1', pane]]) + control.failCancel = true + + expect(await m.backgroundWrite('node-1', '2')).toBe(false) + expect(pane).toEqual({ copyMode: true, bytes: '' }) + expect(control.only.writes).toHaveLength(2) + }) + + it('pins the pane found by the probe when desktop selection changes before the answer', async () => { + const m = await tmuxManager() + await release(ALICE, 'node-1') + const original = { copyMode: true, bytes: '' } + const other = { copyMode: true, bytes: '' } + control.panes = new Map([['nt-node-1', original]]) + control.afterProbe = () => control.panes!.set('nt-node-1', other) + + expect(await m.backgroundWrite('node-1', '2')).toBe(true) + expect(original).toEqual({ copyMode: false, bytes: '2' }) + expect(other).toEqual({ copyMode: true, bytes: '' }) + }) + + it('preserves chunk order while a first background answer waits for copy-mode cancellation', async () => { + const m = await tmuxManager() + await release(ALICE, 'node-1') + const pane = { copyMode: true, bytes: '' } + control.panes = new Map([['nt-node-1', pane]]) + control.holdCancel = true + + const first = m.backgroundWrite('node-1', 'A') + await vi.advanceTimersByTimeAsync(0) + expect(control.heldReplies).toHaveLength(1) + const second = m.backgroundWrite('node-1', 'B') + await vi.advanceTimersByTimeAsync(0) + expect(pane.bytes).toBe('') + control.heldReplies.shift()!() + + expect(await first).toBe(true) + expect(await second).toBe(true) + expect(pane.bytes).toBe('AB') + }) + + it('lets another node answer while one node waits for cancellation', async () => { + const m = await tmuxManager() + await release(ALICE, 'node-1') + await release(ALICE, 'node-2') + // Separate existing shadows give each node its own FIFO. Holding a reply on one client must + // not artificially reorder another command's reply on that same wire. + await m.shadowAttach('node-1') + await m.shadowAttach('node-2') + const slow = { copyMode: true, bytes: '' } + const other = { copyMode: false, bytes: '' } + control.panes = new Map([['nt-node-1', slow], ['nt-node-2', other]]) + control.holdCancel = true + + const first = m.backgroundWrite('node-1', 'A') + await vi.advanceTimersByTimeAsync(0) + expect(await m.backgroundWrite('node-2', 'B')).toBe(true) + expect(other.bytes).toBe('B') + control.heldReplies.shift()!() + expect(await first).toBe(true) + expect(slow.bytes).toBe('A') + }) + + it('retires a shared client whose probe reply cannot confirm the pane, without sending keys', async () => { + const m = await tmuxManager() + await release(ALICE, 'node-1') + control.modeReply = '' // e.g. the initial attach block consumed instead of the probe's reply + + expect(await m.backgroundWrite('node-1', '2')).toBe(false) + expect(control.only.writes).toEqual([MODE('nt-node-1'), 'detach-client\n']) + expect(control.only.killed).toBe(1) + expect(control.calls).toHaveLength(1) + }) + + it('retires an unconfirmed shadow before it can be reused for another quick answer', async () => { + const m = await tmuxManager() + await release(ALICE, 'node-1') + await m.shadowAttach('node-1') + control.modeReply = '' + + expect(await m.backgroundWrite('node-1', '2')).toBe(false) + expect(control.only.killed).toBe(1) + expect(control.calls).toHaveLength(1) + expect(control.only.writes.some((line) => line.includes('-H'))).toBe(false) + expect(m.shadowedTmuxSessions(TMUX_SOCKET)).toEqual([]) }) it('prefers the node’s own shadow to the shared client', async () => { @@ -289,8 +597,11 @@ describe('background writes into released sessions', () => { // burst costs one process, not one per node. expect(control.calls).toHaveLength(1) expect(control.only.writes).toEqual([ + MODE('nt-node-1'), LS_KEYS('nt-node-1'), + MODE('nt-node-2'), LS_KEYS('nt-node-2'), + MODE('nt-node-3'), LS_KEYS('nt-node-3') ]) }) @@ -491,14 +802,15 @@ describe('background writes into released sessions', () => { expect(control.calls).toHaveLength(0) }) - it('still writes into the painter with ptyShadowClients switched off — tier 1 is not a shadow', async () => { + it('still answers a live tmux pane with ptyShadowClients switched off', async () => { const m = await managerWith({ ptyShadowClients: false }) await create(ALICE, 'node-1') // Tier 1 is the session's own pty, which exists with or without this feature. Gating it would // turn the kill switch into "background writes stop working", which is a different setting. expect(await m.backgroundWrite('node-1', 'ls\n')).toBe(true) - expect(spawned[0].writes).toEqual(['ls\n']) + expect(spawned[0].writes).toEqual([]) + expect(tmuxCalls.at(-1)).toContain('send-keys') expect(control.calls).toHaveLength(0) }) @@ -527,6 +839,7 @@ describe('background writes into released sessions', () => { const before = vi.getTimerCount() const writing = m.backgroundWrite('node-1', 'ls\n') // arms the linger + await Promise.resolve() // whole deliveries enter the node's ordering queue first control.only.exit(1) // …and the client dies under it: its own onExit clears `shared` expect(await writing).toBe(false) diff --git a/src/core/pty-coattach.test.ts b/src/core/pty-coattach.test.ts index 323cb3eedc..1fdbef303f 100644 --- a/src/core/pty-coattach.test.ts +++ b/src/core/pty-coattach.test.ts @@ -1380,10 +1380,10 @@ describe('a tmux-backed joiner is told to enable mouse tracking', () => { }) describe('tmuxAttachFlags', () => { - it("keeps -D for the app's own client: exactly ONE tmux client per session", async () => { + it("co-attaches external clients; the create/join ledger keeps one app painter", async () => { const { tmuxAttachFlags } = await import('./pty-manager') - // Co-attach adds subscribers to OUR session — it never adds a tmux client, so -D stays. - expect(tmuxAttachFlags(false)).toEqual(['-A', '-D']) + // Co-attach adds subscribers to OUR session; external viewers are never broadly detached. + expect(tmuxAttachFlags(false)).toEqual(['-A']) // A relay-served (detached) pty mirrors the host's own client → must NOT detach it. expect(tmuxAttachFlags(true)).toEqual(['-A']) }) diff --git a/src/core/pty-composed-input.test.ts b/src/core/pty-composed-input.test.ts new file mode 100644 index 0000000000..68d5a2a91a --- /dev/null +++ b/src/core/pty-composed-input.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { initPlatform, resetPlatformForTests } from './platform' +import { fakePlatform } from './platform-fake' +import { DEFAULT_SETTINGS } from '../shared/types' +import type { ComposedInputResult } from '../shared/composed-input' +import type { ComposedViewerReceipt } from './composed-tmux' +import { NativeWindowsPane } from './native-windows-pane' + +const seam = vi.hoisted(() => ({ capture: vi.fn(), submit: vi.fn(), writes: vi.fn(), exit: undefined as undefined | ((e: { exitCode: number }) => void) })) +vi.mock('./composed-tmux', async (original) => ({ ...(await original()), + captureComposedViewer: seam.capture, submitComposedTmux: seam.submit })) +vi.mock('./tmux-painter', () => ({ trackTmuxPainter: () => ({ close() {}, settled: Promise.resolve() }) })) +vi.mock('./session-host-backend', async () => (await import('./__fixtures__/no-session-host')).noSessionHost()) +vi.mock('./tmux-hint', async (original) => ({ ...(await original()), findFixedTmux: () => '/usr/bin/tmux' })) +vi.mock('node-pty', () => ({ spawn: () => ({ pid: 123, onData() {}, onExit(cb: typeof seam.exit) { seam.exit = cb }, + write: seam.writes, resize() {}, pause() {}, resume() {}, kill() {} }) })) +vi.mock('child_process', () => ({ execFileSync: () => '', + execFile: (_file: string, _args: string[], a?: unknown, b?: unknown) => { + const callback = (typeof a === 'function' ? a : b) as (err: null, value: { stdout: string; stderr: string }) => void + callback?.(null, { stdout: '', stderr: '' }); return {} + } })) + +let userData: string +const r: ComposedViewerReceipt = { socket: 'node-terminal', session: 'nt-owned', viewerPid: 123, + viewerCreated: '1700000000', viewerName: 'client-123', viewerBirth: 'viewer-birth', serverPid: 99, + serverBirth: 'server-birth', sessionCreated: '1700000000', paneId: '%7', panePid: 77, paneBirth: 'pane-birth' } +const input = { kind: 'paste', text: 'composed draft', enter: true } as const +beforeEach(() => { + userData = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-pty-composed-')) + initPlatform(fakePlatform({ userDataDir: userData })) + vi.useFakeTimers() + seam.capture.mockReset().mockResolvedValue(r) + seam.submit.mockReset().mockResolvedValue({ status: 'delivered' } satisfies ComposedInputResult) + seam.writes.mockReset(); seam.exit = undefined +}) +afterEach(() => { vi.useRealTimers(); resetPlatformForTests(); fs.rmSync(userData, { recursive: true, force: true }) }) +async function manager() { + const { PtyManager } = await import('./pty-manager') + const m = new PtyManager() + m.init(() => DEFAULT_SETTINGS) + return m +} +const sinks = () => ({ onData() {}, onExit() {} }) + +describe('PtyManager composed action on the captured relay viewer', () => { + it('captures the spawned local viewer during attach, before any Send', async () => { + const m = await manager() + const id = m.attachDetached('owned', sinks()) + expect(seam.capture).toHaveBeenCalledWith('node-terminal', 'nt-owned', 123, expect.any(Object)) + expect(await m.submitComposed(id, input, () => true)).toEqual({ status: 'delivered' }) + expect(seam.submit).toHaveBeenCalledWith(r, input, expect.any(Object)) + expect(seam.capture).toHaveBeenCalledTimes(1) + expect(seam.writes).not.toHaveBeenCalled() + m.kill(null, id) + }) + it('refuses a detach while attachment receipt is pending, with no replacement/raw write', async () => { + let resolve!: (receipt: ComposedViewerReceipt) => void + seam.capture.mockReturnValue(new Promise((done) => { resolve = done })) + const m = await manager(), id = m.attachDetached('owned', sinks()) + const send = m.submitComposed(id, input, () => true) + m.kill(null, id) + resolve(r) + expect((await send).status).toBe('refused') + expect(seam.submit).not.toHaveBeenCalled() + expect(seam.writes).not.toHaveBeenCalled() + }) + it('keeps stream-current checks inside dispatch and serializes repeated explicit actions', async () => { + const m = await manager(), id = m.attachDetached('owned', sinks()) + let resolve!: (result: ComposedInputResult) => void + let alive = true + seam.submit.mockImplementationOnce((_receipt, _input, b) => { + expect(b.current()).toBe(true) + return new Promise((done) => { resolve = done }) + }) + const first = m.submitComposed(id, input, () => alive) + await Promise.resolve(); await Promise.resolve() + const second = m.submitComposed(id, input, () => alive) + expect(seam.submit).toHaveBeenCalledTimes(1) + alive = false; resolve({ status: 'uncertain' }) + expect((await first).status).toBe('uncertain') + expect((await second).status).toBe('refused') + expect(seam.submit).toHaveBeenCalledTimes(1) + m.kill(null, id) + }) + it('refuses an older unverified backend shim and unknown viewers while preserving raw reports', async () => { + const m = await manager(), id = m.attachDetached('owned', sinks()) + expect((await m.submitComposed('foreign', input, () => true)).status).toBe('refused') + const internals = m as unknown as { sessions: Map; sessionHost?: boolean; proc: { write(text: string): void } }> } + internals.sessions.get(id)!.sessionHost = true + expect((await m.submitComposed(id, input, () => true)).status).toBe('refused') + m.write(null, id, '\x1b[12;34R') + expect(seam.writes).toHaveBeenCalledWith('\x1b[12;34R') + expect(seam.submit).not.toHaveBeenCalled() + internals.sessions.get(id)!.sessionHost = false + m.kill(null, id) + }) + it('routes an owned direct Windows pane through its actual emulator and split writer', async () => { + vi.useRealTimers() + const m = await manager(), id = m.attachDetached('owned', sinks()) + const proc = { pid: 123, write: seam.writes } + const pane = new NativeWindowsPane(proc, { cols: 80, rows: 24, scrollback: 100 }) + pane.recordOutput('\x1b[?2004h') + const internals = m as unknown as { sessions: Map } + internals.sessions.get(id)!.nativeWindowsPane = pane + expect(await m.submitComposed(id, input, () => true)).toEqual({ status: 'delivered' }) + expect(seam.writes.mock.calls.map(([data]) => data)).toEqual(['\x1b[200~composed draft\x1b[201~', '\r']) + expect(seam.submit).not.toHaveBeenCalled() + let current = false + expect((await m.submitComposed(id, input, () => current)).status).toBe('refused') + expect(seam.writes).toHaveBeenCalledTimes(2) + m.kill(null, id) + }) +}) diff --git a/src/core/pty-history-scroll.test.ts b/src/core/pty-history-scroll.test.ts new file mode 100644 index 0000000000..012347bb49 --- /dev/null +++ b/src/core/pty-history-scroll.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PtyManager } from './pty-manager' +import { NativeWindowsPane } from './native-windows-pane' +import type { NativeScrollResult } from '../shared/history-scroll' + +vi.mock('node-pty', () => ({ spawn: () => { throw new Error('this route test never creates a PTY') } })) +const cleanup: Array<() => void> = [] +afterEach(() => { cleanup.splice(0).forEach((close) => close()); vi.restoreAllMocks() }) +function fixture() { + const write = vi.fn(), background = vi.fn() + const sessions = new Map() + // The manager's real dispatch is exercised on admitted session objects; the OS spawn leaf is + // deliberately absent. No actual ConPTY, foreground application or tmux process is claimed. + const manager = Object.create(PtyManager.prototype) as PtyManager + Object.assign(manager, { sessions, backgroundWrite: background }) + const session = { proc: { write }, tmuxBacked: false, persistKey: undefined as string | undefined, + sessionHost: false, nodeId: undefined, peers: new Map(), sshRemote: false, + nativeWindowsPane: undefined as NativeWindowsPane | undefined } + sessions.set('viewer', session) + return { manager, sessions, session, write, background } +} + +describe('PtyManager scrolling on its exact attached backend', () => { + it('reads the direct pane actual emulator with mouse off and sends requested wheel encoding only after output', async () => { + const f = fixture(), pane = new NativeWindowsPane({ pid: 42, write: f.write }, { cols: 20, rows: 5, scrollback: 100 }) + cleanup.push(() => pane.dispose()); f.session.nativeWindowsPane = pane + pane.recordOutput('pre-attach history\r\n' + 'more history\r\n'.repeat(12)) + const history = await f.manager.scrollAttached(null, 'viewer', true, 2, true, () => true) + expect(history.status).toBe('history') + if (history.status === 'history') expect(history.capture?.rows.some((row) => row.text.includes('pre-attach'))).toBe(true) + expect(f.write).not.toHaveBeenCalled() + pane.recordOutput('\x1b[?1000h\x1b[?1006l') + expect(await f.manager.scrollAttached(null, 'viewer', false, 2, false, () => true)).toEqual({ status: 'input' }) + expect(f.write.mock.calls).toEqual([['\x1b[Ma!!'], ['\x1b[Ma!!']]) + expect(f.background).not.toHaveBeenCalled() + }) + it('binds a queued backend action to the captured session object, not a replacement under the same id', async () => { + const f = fixture(); f.session.sessionHost = true + let release!: () => void, entered!: () => void + const gate = new Promise((done) => { release = done }), admitted = new Promise((done) => { entered = done }) + const scroll = vi.fn(async (up, lines, capture, current): Promise => { + expect([up, lines, capture]).toEqual([true, 3, true]); entered(); await gate + return current() ? { status: 'input' } : { status: 'refused', message: 'retired captured owner' } + }) + Object.assign(f.session.proc, { scrollForHistory: scroll }) + const action = f.manager.scrollAttached(null, 'viewer', true, 3, true, () => true) + await admitted; const replacement = { ...f.session, proc: { write: vi.fn(), scrollForHistory: vi.fn() } } + f.sessions.set('viewer', replacement); release() + expect((await action).status).toBe('refused') + expect(scroll).toHaveBeenCalledTimes(1); expect(replacement.proc.scrollForHistory).not.toHaveBeenCalled() + expect(f.write).not.toHaveBeenCalled(); expect(f.background).not.toHaveBeenCalled() + }) + it('preserves local and SSH-project tmux viewer wheel bytes without a background/name-only route', async () => { + for (const sshRemote of [false, true]) { + const f = fixture(); Object.assign(f.session, { tmuxBacked: true, persistKey: 'exact-retained-node', sshRemote }) + expect(await f.manager.scrollAttached(null, 'viewer', true, 2, true, () => true)).toEqual({ status: 'input' }) + expect(await f.manager.scrollAttached(null, 'viewer', false, 1, false, () => true)).toEqual({ status: 'input' }) + expect(f.write.mock.calls).toEqual([['\x1b[<64;1;1M'], ['\x1b[<64;1;1M'], ['\x1b[<65;1;1M']]) + expect(f.background).not.toHaveBeenCalled() + } + }) + it('refuses released, unclassified, partial-tmux and old session-host routes before any bytes', async () => { + const f = fixture() + expect((await f.manager.scrollAttached(null, 'released', true, 1, false, () => true)).status).toBe('refused') + for (const flags of [{}, { tmuxBacked: true }, { tmuxBacked: false, persistKey: 'node' }, { sessionHost: true }]) { + Object.assign(f.session, { tmuxBacked: false, persistKey: undefined, sessionHost: false }, flags) + expect((await f.manager.scrollAttached(null, 'viewer', true, 1, false, () => true)).status).toBe('refused') + } + expect(f.write).not.toHaveBeenCalled(); expect(f.background).not.toHaveBeenCalled() + }) + it('refuses malformed distances and stale stream ownership before dispatching tmux input', async () => { + const f = fixture(); Object.assign(f.session, { tmuxBacked: true, persistKey: 'node' }) + for (const lines of [0, 21, 1.5, NaN]) expect((await f.manager.scrollAttached(null, 'viewer', true, lines, true, () => true)).status).toBe('refused') + expect((await f.manager.scrollAttached(null, 'viewer', true, 1, true, () => false)).status).toBe('refused') + expect(f.write).not.toHaveBeenCalled() + }) + it('stops tmux wheel repetition after retirement or a possible write and keeps the result uncertain', async () => { + for (const failure of ['retire', 'throw'] as const) { + const f = fixture(); Object.assign(f.session, { tmuxBacked: true, persistKey: 'node' }); let current = true + f.write.mockImplementationOnce(() => { if (failure === 'throw') throw new Error('possible accepted wheel'); current = false }) + expect((await f.manager.scrollAttached(null, 'viewer', true, 3, false, () => current)).status).toBe('uncertain') + expect(f.write.mock.calls).toEqual([['\x1b[<64;1;1M']]); expect(f.background).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/core/pty-idle-reap.test.ts b/src/core/pty-idle-reap.test.ts index 4c04070999..abdffed337 100644 --- a/src/core/pty-idle-reap.test.ts +++ b/src/core/pty-idle-reap.test.ts @@ -70,7 +70,7 @@ const execCalls: Array<{ file: string; args: string[] }> = [] const liveTmuxSessions = new Set() vi.mock('child_process', () => { - type Cb = (err: Error | null, res?: { stdout: string; stderr: string }) => void + type Cb = (err: Error | null, res?: string | { stdout: string; stderr: string }, stderr?: string) => void const execFile = (file: string, args: string[], a?: unknown, b?: unknown): unknown => { const cb = (typeof a === 'function' ? a : b) as Cb | undefined execCalls.push({ file, args }) @@ -80,7 +80,7 @@ vi.mock('child_process', () => { if (liveTmuxSessions.has(target)) ok('') else cb?.(Object.assign(new Error('no such session'), { code: 1 })) } else if (args[0] === '-ilc') { - ok('__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__') + cb?.(null, '__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__', '') } else { ok('') } diff --git a/src/core/pty-join-only.test.ts b/src/core/pty-join-only.test.ts index dfc9dfe98c..4bc937ee45 100644 --- a/src/core/pty-join-only.test.ts +++ b/src/core/pty-join-only.test.ts @@ -283,11 +283,13 @@ describe('joinOnly: a viewer may watch a terminal but never start one', () => { expect(argv).not.toContain('-D') }) - it('control: the owner\'s warm reattach still takes the session over (-D)', async () => { + it('control: the owner\'s warm reattach preserves other tmux clients', async () => { await tmuxManager('present') await create(OWNER, {}) expect(spawned).toHaveLength(1) - expect(spawned[0].args).toContain('-D') + expect(spawned[0].args).toContain('-A') + expect(spawned[0].args).not.toContain('-D') + expect(spawned[0].args).not.toContain('-d') }) it('an absent joinOnly still spawns (the non-viewer path is unchanged)', async () => { diff --git a/src/core/pty-manager-platform.test.ts b/src/core/pty-manager-platform.test.ts index eb7bc43cfc..c71cc0ba84 100644 --- a/src/core/pty-manager-platform.test.ts +++ b/src/core/pty-manager-platform.test.ts @@ -22,6 +22,8 @@ describe('PtyManager platform registration', () => { IPC.ptyKill, IPC.ptyDestroy, IPC.ptySendText, + IPC.ptyWakeSleeping, + IPC.ptySendChatPrompt, IPC.ptyReadScrollback, // Registered in core, so BOTH shells serve it. That placement is the point: the hibernation // exit fails CLOSED on a pane it cannot read, so a channel that reached only the desktop diff --git a/src/core/pty-manager.managed.test.ts b/src/core/pty-manager.managed.test.ts new file mode 100644 index 0000000000..e8e0ed6870 --- /dev/null +++ b/src/core/pty-manager.managed.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { fakePlatform } from './platform-fake' +import { initPlatform, resetPlatformForTests } from './platform' +import { DEFAULT_SETTINGS, type PtyCreateOptions } from '../shared/types' +import { paneOwnerProject, resetPaneOwnershipForTests } from './agents/pane-ownership' +import type { ManagedPaneReceipt } from '../shared/managed-terminal' + +const receipt: ManagedPaneReceipt = { + version: 1, creationId: '1e8dd6ac-050b-4e63-a331-9f6e9df8b7fb', nodeId: 'term-test-1234', projectId: 'local-project', + socket: 'node-terminal', session: 'nt-term-test-1234', paneId: '%42', panePid: 1234, + paneBirth: 'linux:12345678-1234-1234-1234-123456789012:123', sessionCreated: '1770000000' +} + +const native = vi.hoisted(() => ({ spawn: vi.fn((_file: string, _args: string[], _options: unknown) => ({ pid: 4321, onData() {}, onExit() {}, write() {}, resize() {}, kill() {}, pause() {}, resume() {} })) })) +vi.mock('node-pty', () => native) +vi.mock('./exec-path', async (original) => ({ + ...(await original()), shellPathNow: () => '/usr/bin:/bin', resolveShellPath: async () => '/usr/bin:/bin' +})) +vi.mock('./codex-accounts-core', async (original) => ({ + ...(await original()), + resolveCodexSessionScope: () => ({ CODEX_HOME: '/fixture-managed-codex', NODETERM_CODEX_ACCOUNT_ID: 'personal' }), + codexSessionEnv: () => ({ CODEX_HOME: '/fixture-managed-codex', NODETERM_CODEX_ACCOUNT_ID: 'personal' }) +})) + +let root: string +async function manager() { + const { PtyManager } = await import('./pty-manager') + const m = new PtyManager() as any + m.tmuxPath = '/usr/bin/tmux' + m.confPath = '/dev/null' + m.getSettings = () => ({ ...DEFAULT_SETTINGS, tmuxEnabled: true }) + m.ensureSnapshotTimer = vi.fn() + m.ensureReapTimer = vi.fn() + return m +} +function options(): PtyCreateOptions { return { persistKey: receipt.nodeId, ownerProjectId: receipt.projectId, cwd: root, cols: 80, rows: 24 } } + +// Managed V1 requires a real POSIX tmux pane and kernel process birth. Windows keeps the +// existing session-host New path; never fabricate a tmux receipt for that backend. +describe.skipIf(process.platform !== 'linux' && process.platform !== 'darwin')('PtyManager managed creation runtime ownership', () => { + beforeEach(() => { + native.spawn.mockClear(); resetPaneOwnershipForTests() + const platform = fakePlatform(); root = platform.userDataDir; initPlatform(platform) + }) + afterEach(() => { resetPlatformForTests(); fs.rmSync(root, { recursive: true, force: true }) }) + it('passes create-only flags through normal local spawn and marks the session environment', async () => { + const m = await manager() + m.spawnSession(options(), 0, undefined, undefined, null, receipt.creationId) + expect(native.spawn).toHaveBeenCalledOnce() + const args = native.spawn.mock.calls[0][1] as string[] + expect(args).toContain('new-session') + expect(args).toContain('NODETERM_MANAGED_CREATION_ID=' + receipt.creationId) + expect(args).not.toContain('-A') + expect(args).not.toContain('-D') + expect(args).toContain(root) + expect(paneOwnerProject(receipt.nodeId)).toBeUndefined() + }) + it('records ownership only after the exact creation receipt and runtime generation are confirmed', async () => { + const m = await manager(); const session = { nodeId: receipt.nodeId } + m.spawnNew = vi.fn(async (_client, _opts, exclusive) => { + expect(exclusive.creationId).toBe(receipt.creationId) + expect(exclusive.current()).toBe(true) + m.sessions.set('fresh', session); m.byPersistKey.set(receipt.nodeId, 'fresh') + return { sessionId: 'fresh', fresh: true, persistent: true } + }) + m.readManagedPane = vi.fn(async () => receipt) + expect(await m.createManagedHeadless(options(), receipt.creationId, () => true)).toEqual(receipt) + expect(paneOwnerProject(receipt.nodeId)).toBe(receipt.projectId) + expect(await m.verifyManagedPane({ ...receipt, paneBirth: 'other' }, () => true)).toBe(false) + m.sessions.set('fresh', { nodeId: receipt.nodeId }) + expect(await m.verifyManagedPane(receipt, () => true)).toBe(false) + }) + it('normal cold spawn with an exclusive request stays unproven until receipt readiness', async () => { + const m = await manager() + m.tmuxSessionExists = async () => false + m.projectSpawnOverrides = async () => null + const result = await m.spawnNew(0, options(), { creationId: receipt.creationId, current: () => true }) + expect(result.fresh).toBe(true) + expect(result.persistent).toBe(true) + expect(paneOwnerProject(receipt.nodeId)).toBeUndefined() + expect(native.spawn.mock.calls[0][1]).not.toContain('-A') + }) + it('a warm backend probe and a stopped service before synchronous spawn both refuse without a PTY', async () => { + const m = await manager(); m.tmuxSessionExists = async () => true + await expect(m.spawnNew(0, options(), { creationId: receipt.creationId, current: () => true })).rejects.toThrow(/exists/) + expect(native.spawn).not.toHaveBeenCalled() + let current = true + m.tmuxSessionExists = async () => false + m.projectSpawnOverrides = async () => { current = false; return null } + await expect(m.spawnNew(0, options(), { creationId: receipt.creationId, current: () => current })).rejects.toThrow(/current/) + expect(native.spawn).not.toHaveBeenCalled() + expect(paneOwnerProject(receipt.nodeId)).toBeUndefined() + }) + it('refuses a folder removed or replaced during preparation instead of opening Home', async () => { + for (const replace of [false, true]) { + const m = await manager(); const cwd = path.join(root, 'project-' + replace) + fs.mkdirSync(cwd); m.tmuxSessionExists = async () => false + m.projectSpawnOverrides = async () => { + fs.renameSync(cwd, cwd + '-original') + if (replace) fs.mkdirSync(cwd) + return null + } + await expect(m.createManagedHeadless({ ...options(), cwd }, receipt.creationId, () => true)).rejects.toThrow(/folder changed/) + expect(native.spawn).not.toHaveBeenCalled() + expect(paneOwnerProject(receipt.nodeId)).toBeUndefined() + } + }) + it('refuses an account removed or made pending during preparation even if its old directory remains', async () => { + const accountDir = path.join(root, 'claude-accounts', 'personal'); fs.mkdirSync(accountDir, { recursive: true }) + for (const pending of [false, true]) { + const m = await manager(); m.tmuxSessionExists = async () => false + let accounts: any[] = [{ id: 'personal', label: 'Personal' }] + m.getSettings = () => ({ ...DEFAULT_SETTINGS, tmuxEnabled: true, claudeAccounts: accounts }) + m.projectSpawnOverrides = async () => { accounts = pending ? [{ ...accounts[0], pending: true }] : []; return null } + await expect(m.createManagedHeadless({ ...options(), agentId: 'claude', accountId: 'personal' }, receipt.creationId, () => true)).rejects.toThrow(/account.*available/) + expect(native.spawn).not.toHaveBeenCalled() + } + }) + it('keeps the ordinary stale-folder fallback for historical renderer attaches', async () => { + const m = await manager() + m.spawnSession({ ...options(), cwd: path.join(root, 'missing') }, 0, undefined, undefined, null) + expect(native.spawn).toHaveBeenCalledOnce() + expect(native.spawn.mock.calls[0][1]).toContain((await import('node:os')).homedir()) + }) + it('scopes managed Codex through its own account without demanding a Claude directory', async () => { + const m = await manager() + m.getSettings = () => ({ ...DEFAULT_SETTINGS, tmuxEnabled: true, codexAccounts: [{ id: 'personal', label: 'Personal' }] }) + let id: string | undefined + expect(() => { id = m.spawnSession({ ...options(), agentId: 'codex', accountId: 'personal' }, 0, undefined, undefined, null, receipt.creationId) }).not.toThrow() + expect(native.spawn).toHaveBeenCalledOnce() + expect(m.sessions.get(id).accountFallback).toBe(false) + const env = (native.spawn.mock.calls[0][2] as { env: Record }).env + expect(env.CODEX_HOME).toBe('/fixture-managed-codex') + expect(env.CLAUDE_CONFIG_DIR).toBeUndefined() + }) + it('refuses occupied names without spawning or acquiring ownership', async () => { + const m = await manager(); m.spawnNew = vi.fn() + m.sessions.set('warm', { nodeId: receipt.nodeId }); m.byPersistKey.set(receipt.nodeId, 'warm') + await expect(m.createManagedHeadless(options(), receipt.creationId, () => true)).rejects.toThrow() + expect(m.spawnNew).not.toHaveBeenCalled() + expect(paneOwnerProject(receipt.nodeId)).toBeUndefined() + }) + it('a generation replaced during proof is left alone, never cleaned up by a reused id', async () => { + const m = await manager(); const session = { nodeId: receipt.nodeId } + m.spawnNew = vi.fn(async () => { m.sessions.set('fresh', session); m.byPersistKey.set(receipt.nodeId, 'fresh'); return { sessionId: 'fresh', fresh: true, persistent: true } }) + m.readManagedPane = vi.fn(async () => { m.sessions.set('fresh', { nodeId: receipt.nodeId }); return receipt }) + m.kill = vi.fn(); m.destroySession = vi.fn() + await expect(m.createManagedHeadless(options(), receipt.creationId, () => true)).rejects.toThrow(/confirmed/) + expect(m.kill).not.toHaveBeenCalled() + expect(m.destroySession).not.toHaveBeenCalled() + expect(paneOwnerProject(receipt.nodeId)).toBeUndefined() + }) + it('consumes a launch permit before proof so a changed/uncertain generation is never retried', async () => { + const m = await manager(); const session = { nodeId: receipt.nodeId } + m.sessions.set('fresh', session); m.byPersistKey.set(receipt.nodeId, 'fresh') + m.managedPanes.set(receipt.creationId, { receipt, sessionId: 'fresh', session, attempted: false }) + m.paneOwner = vi.fn(async () => ({ paneId: receipt.paneId, panePid: receipt.panePid, pids: [receipt.panePid], command: 'bash' })) + m.readManagedPane = vi.fn(async () => ({ ...receipt, paneBirth: 'replacement' })) + expect(await m.deliverManagedLaunch(receipt, 'claude', () => true)).toBe(false) + expect(await m.deliverManagedLaunch(receipt, 'claude', () => true)).toBe(false) + expect(m.readManagedPane).toHaveBeenCalledOnce() + expect(m.paneOwner).toHaveBeenCalledOnce() + }) + it('retires proof with its exact runtime generation and releases the retained session object', async () => { + const m = await manager(); const session = { nodeId: receipt.nodeId, indexKey: receipt.nodeId } + m.sessions.set('fresh', session); m.byPersistKey.set(receipt.nodeId, 'fresh') + m.managedPanes.set(receipt.creationId, { receipt, sessionId: 'fresh', session, attempted: true }) + m.forget('fresh', session) + expect(m.managedPanes.size).toBe(0) + expect(await m.verifyManagedPane(receipt, () => true)).toBe(false) + }) + it('drops all managed launch proof at manager shutdown', async () => { + const m = await manager() + m.managedPanes.set(receipt.creationId, { receipt, sessionId: 'fresh', session: {}, attempted: false }) + await m.killAll() + expect(m.managedPanes.size).toBe(0) + expect(await m.deliverManagedLaunch(receipt, 'claude', () => true)).toBe(false) + }) + it('rechecks the service fence and runtime after final proof, before typing', async () => { + const m = await manager(); const session = { nodeId: receipt.nodeId }; let current = true + m.sessions.set('fresh', session); m.byPersistKey.set(receipt.nodeId, 'fresh') + m.managedPanes.set(receipt.creationId, { receipt, sessionId: 'fresh', session, attempted: false }) + m.paneOwner = vi.fn(async () => ({ paneId: receipt.paneId, panePid: receipt.panePid, pids: [receipt.panePid], command: 'bash' })) + m.readManagedPane = vi.fn(async () => { current = false; return receipt }) + m.managedLaunchRun = vi.fn(async () => ({ stdout: 'nt-managed-delivered' })) + expect(await m.deliverManagedLaunch(receipt, 'claude', () => current)).toBe(false) + expect(m.managedLaunchRun).not.toHaveBeenCalled() + }) + it('submits once only after shell and final birth proof, with the command on stdin', async () => { + const m = await manager(); const session = { nodeId: receipt.nodeId } + m.sessions.set('fresh', session); m.byPersistKey.set(receipt.nodeId, 'fresh') + m.managedPanes.set(receipt.creationId, { receipt, sessionId: 'fresh', session, attempted: false }) + m.paneOwner = vi.fn(async () => ({ paneId: receipt.paneId, panePid: receipt.panePid, pids: [receipt.panePid], command: 'bash' })) + m.readManagedPane = vi.fn(async () => receipt) + m.managedLaunchRun = vi.fn(async () => ({ stdout: 'nt-managed-delivered' })) + expect(await m.deliverManagedLaunch(receipt, 'claude', () => true)).toBe(true) + expect(await m.deliverManagedLaunch(receipt, 'claude', () => true)).toBe(false) + expect(m.managedLaunchRun).toHaveBeenCalledOnce() + const [file, args, body] = m.managedLaunchRun.mock.calls[0] + expect(file).toBe('/usr/bin/tmux'); expect(args.join(' ')).not.toContain('claude'); expect(body).toBe('claude') + }) +}) diff --git a/src/core/pty-manager.pane-owner.test.ts b/src/core/pty-manager.pane-owner.test.ts index fd059e7fe8..d284314aea 100644 --- a/src/core/pty-manager.pane-owner.test.ts +++ b/src/core/pty-manager.pane-owner.test.ts @@ -11,6 +11,7 @@ import { fakePlatform } from './platform-fake' import { TMUX_SOCKET, sessionName } from './tmux-naming' import { PANE_OWNER_FMT } from './agents/pane-owner' import { RMT_TMUX_SOCKET } from './remote-ssh/control-master' +import type { SleepingWakeRequest } from '../shared/agents/sleeping-wake' /** Every `runAsync` in pty-manager lands here. `answer` decides what each call resolves to. */ const calls: Array<{ file: string; args: string[] }> = [] @@ -58,13 +59,15 @@ const NODE = 'node-1' const hostMessaging = vi.hoisted(() => ({ owner: vi.fn(async () => null as unknown), pasteReady: vi.fn(async () => false), - send: vi.fn(async () => false) + send: vi.fn(async () => false), + wake: vi.fn(async () => false) })) vi.mock('./session-host-backend', async (original) => ({ ...(await original()), sessionHostMessageOwner: hostMessaging.owner, sessionHostMessagePasteReady: hostMessaging.pasteReady, - sessionHostMessageEnvelope: hostMessaging.send + sessionHostMessageEnvelope: hostMessaging.send, + sessionHostWakeSleeping: hostMessaging.wake })) const TARGET = sessionName(NODE) const TTY = '/dev/pts/7' @@ -85,12 +88,63 @@ async function manager(opts: { tmux?: string | null; sshRemote?: unknown } = {}) tmuxPath: string | null sessions: Map paneOwner(k: string): Promise + historySearch(k: string, query: string): Promise } mgr.tmuxPath = opts.tmux === undefined ? '/usr/bin/tmux' : opts.tmux mgr.sessions.set('sess-1', { persistKey: NODE, sshRemote: opts.sshRemote }) return mgr } +describe('PtyManager.historySearch attached-generation capture', () => { + beforeEach(() => { calls.length = 0; ssh.path = '/usr/bin/ssh'; vi.resetModules(); initPlatform(fakePlatform()) }) + afterEach(() => { resetPlatformForTests() }) + it('pins the exact local pane and captures all plain joined history', async () => { + script.answer = (_file, args) => ({ stdout: args.includes('display-message') ? '%42\n' : 'old needle\n' + 'recent\n'.repeat(500) }) + const mgr = await manager() + expect((await mgr.historySearch('sess-1', 'needle')).rows).toEqual([{ line: 0, text: 'old needle' }]) + expect(calls.map((c) => c.args)).toEqual([ + ['-L', TMUX_SOCKET, 'display-message', '-p', '-t', '=nt-node-1:', '#{pane_id}'], + ['-L', TMUX_SOCKET, 'capture-pane', '-p', '-J', '-t', '%42', '-S', '-'] + ]) + }) + it('routes remote history through the actual master even without a local tmux', async () => { + script.answer = () => ({ stdout: 'remote needle\n' }) + const mgr = await manager({ tmux: null, sshRemote: { conn: { host: 'remote', user: 'u' }, controlPath: '/tmp/owned-master' } }) + expect((await mgr.historySearch('sess-1', 'needle')).rows[0].text).toBe('remote needle') + expect(calls).toHaveLength(1) + expect(calls[0].file).toBe('/usr/bin/ssh') + expect(calls[0].args).toContain('ControlPath=/tmp/owned-master') + expect(calls[0].args.at(-1)).toContain("'=nt-node-1:'") + expect(calls[0].args.at(-1)).toContain('capture-pane -p -J -t "$nt_history_pane" -S -') + }) + it('refuses missing/invalid panes and propagates capture failures', async () => { + const mgr = await manager() + await expect(mgr.historySearch('foreign', 'x')).rejects.toThrow(/no longer attached/) + expect(calls).toHaveLength(0) + script.answer = () => ({ stdout: 'not a pane' }) + await expect(mgr.historySearch('sess-1', 'x')).rejects.toThrow(/could not be resolved/) + expect(calls).toHaveLength(1) + script.answer = (_f, args) => { if (args.includes('capture-pane')) throw new Error('capture failed'); return { stdout: '%42' } } + await expect(mgr.historySearch('sess-1', 'x')).rejects.toThrow('capture failed') + }) + it('rejects a generation replaced during capture', async () => { + const mgr = await manager() + script.answer = (_f, args) => { + if (args.includes('capture-pane')) { mgr.sessions.set('sess-1', { persistKey: NODE }); return { stdout: 'needle' } } + return { stdout: '%42' } + } + await expect(mgr.historySearch('sess-1', 'needle')).rejects.toThrow(/terminal changed/) + }) + it('uses the real native retained screen even when tmux persistence is off', async () => { + const mgr = await manager({ tmux: null }) + const historyText = vi.fn(async () => 'native needle') + mgr.sessions.set('sess-1', { nodeId: NODE, nativeWindowsPane: { historyText } }) + expect((await mgr.historySearch('sess-1', 'needle')).rows).toEqual([{ line: 0, text: 'native needle' }]) + expect(historyText).toHaveBeenCalledOnce() + expect(calls).toHaveLength(0) + }) +}) + /** * The default healthy answers. Local: tmux answers the format, `ps` answers the tty listing. * SSH: the ONE combined exec answers the fenced identity line plus the `ps` rows in one stdout @@ -109,6 +163,45 @@ function healthy(file: string, args: string[]): { stdout: string } { } describe('PtyManager.paneOwner', () => { + it('wakes live and released session-host shells only through the generation-bound extension', async () => { + const mgr = await manager() as unknown as Pick & { + sessions: Map; byPersistKey: Map; released: Map + } + const rawWrite = vi.fn() + const expected = { panePid: 10, tty: 'win32-console:10', paneId: 'win32:host-generation:birth', command: 'pwsh', argv: ['pwsh'], pids: [10], processBirths: ['birth'] } + const request: SleepingWakeRequest = { nodeId: NODE, agentId: 'codex', command: 'codex resume saved', exitedByUs: true, + recorded: { command: 'pwsh', panePid: 10, paneId: expected.paneId } } + mgr.sessions.set('host-session', { persistKey: NODE, indexKey: NODE, sessionHost: true, proc: { write: rawWrite } }) + mgr.byPersistKey.set(NODE, 'host-session') + hostMessaging.owner.mockResolvedValue(expected) + hostMessaging.wake.mockResolvedValueOnce(true) + expect(await mgr.wakeSleeping(request)).toEqual({ delivered: true, verdict: 'resume' }) + expect(hostMessaging.wake).toHaveBeenCalledWith(TARGET, '\x1b' + request.command + '\r', expected) + hostMessaging.wake.mockClear() + expect(await mgr.wakeSleeping(request)).toEqual({ delivered: false, verdict: 'delivery-failed' }) + expect(rawWrite).not.toHaveBeenCalled() // Unsupported/uncertain extension never falls back. + mgr.sessions.clear(); mgr.byPersistKey.clear() + mgr.released.set(NODE, { sessionId: 'host-session', remote: false, sessionHost: true }) + hostMessaging.wake.mockResolvedValueOnce(true) + expect((await mgr.wakeSleeping(request)).delivered).toBe(true) + expect(hostMessaging.wake).toHaveBeenCalledTimes(2) + expect(calls).toHaveLength(0) + }) + + it('uses the direct native birth-checked adapter instead of ordinary PTY input', async () => { + const mgr = await manager({ tmux: null }) as unknown as Pick & { + sessions: Map; byPersistKey: Map + } + const owner = { panePid: 10, tty: 'win32-console:10', paneId: 'native:birth', command: 'pwsh', argv: ['pwsh'], pids: [10], processBirths: ['birth'] } + const write = vi.fn(), wakeSleeping = vi.fn(async () => true) + mgr.sessions.set('native', { indexKey: NODE, proc: { write }, nativeWindowsPane: { owner: async () => owner, wakeSleeping } }) + mgr.byPersistKey.set(NODE, 'native') + const request: SleepingWakeRequest = { nodeId: NODE, agentId: 'codex', command: 'codex resume saved', exitedByUs: true, + recorded: { command: 'pwsh', panePid: 10, paneId: owner.paneId } } + expect((await mgr.wakeSleeping(request)).delivered).toBe(true) + expect(wakeSleeping).toHaveBeenCalledExactlyOnceWith('\x1b' + request.command + '\r', owner) + expect(write).not.toHaveBeenCalled() + }) it('routes a persistent Windows generation exclusively through the host extension', async () => { const mgr = await manager() as unknown as { sessions: Map diff --git a/src/core/pty-manager.ts b/src/core/pty-manager.ts index 0e7282b155..6f2d4da3a9 100644 --- a/src/core/pty-manager.ts +++ b/src/core/pty-manager.ts @@ -1,5 +1,8 @@ import type { ChatPromptBlocked, ChatPromptResult, TextDeliveryResult } from '../shared/text-delivery' import { claudeScreenBlocksInput, readClaudeScreen } from '../shared/agents/claude-screen' +import type { ManagedPaneReceipt } from '../shared/managed-terminal' +import { MANAGED_CREATION_UUID } from '../shared/managed-terminal' +import { MANAGED_PANE_FORMAT, managedCreateFlags, managedLaunchPlan, parseManagedPane, readManagedProcessBirth, sameManagedPane } from './managed-pane' import os from 'os' import fs from 'fs' import path from 'path' @@ -36,6 +39,9 @@ import { remoteTypedArgs, remoteTmuxEnterArgs, remoteCapturePaneArgs, + remoteHistoryCaptureArgs, + remoteCaptureScreenArgs, + remoteTmuxSendKeysArgs, remoteCaptureVisibleArgs, remoteTmuxWatcherArgs, remoteWindowSizeArgs, @@ -68,6 +74,8 @@ import type { SshConnection } from '../shared/ssh' import { recordPendingRemoteKill, type PendingRemoteKill } from './pending-remote-kills' import { probeAgentSockToPin } from './remote-ssh/agent-probe' import { parsePaneCursor } from './pane-cursor' +import { HISTORY_MAX_BYTES, searchTerminalHistory, type HistorySearch } from './terminal-history' +import type { NativeScrollResult } from '../shared/history-scroll' import { classifyPaneCwd } from './pane-cwd' import { localCaptureVisibleArgs, @@ -120,6 +128,7 @@ import { } from './tmux-naming' import { localTypedArgs, localTypedEnv, typeThenSubmitWhenSettled } from './typed-input' import { encodeSendKeysHex } from './tmux-control' +import { deliverSleepingWake, type SleepingWakeRequest, type SleepingWakeResult } from '../shared/agents/sleeping-wake' import { ZELLIJ_NESTING_ENV, zellijAttachArgs, @@ -142,6 +151,9 @@ import { normalizeSessionBackend } from '../shared/session-backend' import { releasePty, type ReleasablePty } from './pty-release' import { terminateWindowsProcessTree } from '../session-host/windows-process-tree' import { effectiveSize, type PtySize } from './pty-size' +import { trackTmuxPainter } from './tmux-painter' +import { captureComposedViewer, submitComposedTmux, type ComposedViewerReceipt } from './composed-tmux' +import { COMPOSED_INPUT_UNSUPPORTED, parseComposedInput, type ComposedInput, type ComposedInputResult } from '../shared/composed-input' import { machOArch, archMismatch } from './macho-arch' import { writeScrollback, readScrollback, deleteScrollback } from './scrollback-store' import { snapshotDue } from './scrollback-cadence' @@ -151,7 +163,8 @@ import { envPathKey, findExecutableSync, findInPathString, resolveShellPath, she import { AUTH_ENV_STRIP, accountTmuxEnvArgs, - isReservedSpawnEnvKey + isReservedSpawnEnvKey, + normalizeLinkedConfigDir } from './claude-accounts-core' import { AUTH_ENV_STRIP as CODEX_AUTH_ENV_STRIP, @@ -204,6 +217,7 @@ import { attachExistingSessionHostPty, createSessionHostPty, sessionHostCapture, + sessionHostHistorySearch, sessionHostHasSession, sessionHostKillSession, sessionHostListSessions, @@ -211,6 +225,7 @@ import { sessionHostMessageOwner, sessionHostMessagePasteReady, sessionHostMessageEnvelope, + sessionHostWakeSleeping, sessionHostSendKeys, sessionHostSupported, SessionHostProtocolCompatibilityError @@ -746,6 +761,10 @@ function isWatcherCreate(options: PtyCreateOptions): boolean { interface Session { proc: pty.IPty + /** Attachment-time receipt of this relay viewer's exact local tmux pane; never re-resolved. */ + composedViewer?: Promise + /** Private ownership receipt and bounded takeover claim for this exact local app painter. */ + closeTmuxPainter?: () => void nativeWindowsPane?: NativeWindowsPane /** Every VIEW watching this session, keyed by the composite `(ClientId, viewerId)` (`SubKey`). * Co-attach: ONE pty and ONE tmux client, N subscribers — a second client on the same persistKey @@ -894,6 +913,8 @@ export type RelayAttachPrep = | { kind: 'ready' remote: boolean + /** Local cold preparation completed before the host answers; rechecks the live wrapper at commit. */ + readonly fresh?: boolean /** Does the session exist RIGHT NOW (fail-safe toward "exists")? Ask before `attach`. */ sessionExists(): Promise /** The current visible screen, '' when there is none. */ @@ -915,20 +936,11 @@ export interface DetachedSinks { adaptsToSize?: boolean } -/** - * tmux attach flags. `-A` = attach-or-create. `-D` = detach OTHER clients on attach. - * - * `-D` STAYS for the app's own client, and co-attach does not change that: a second viewer - * subscribes to the existing `Session` in this process — it does NOT start a second tmux client. - * The app therefore always has exactly ONE tmux client per session, so tmux's own multi-client - * size negotiation never engages and "smallest subscriber wins" is decided by us (pty-size.ts). - * A relay-served (detached) pty is one exception: the host's local client is already attached - * to the same session and must be mirrored, not kicked off. A JOIN-ONLY reattach (a hosted-relay - * viewer, `PtyCreateOptions.joinOnly`) is the other: a watch-only view must never detach the - * user's own `tmux attach`, another app on the same socket, or a relay-served pty. - */ -export function tmuxAttachFlags(mirror: boolean): string[] { - return mirror ? ['-A'] : ['-A', '-D'] +/** Attach beside external viewers in either order (A13). App-owned painter takeover is separately + * attested by PID/birth in tmux-painter; -D cannot distinguish a phone from an old app viewer. + * Keep the argument seam for callers of the former relay-only policy. */ +export function tmuxAttachFlags(_detached: boolean, _relayClientLive = false): string[] { + return ['-A'] } // Output coalescing: a fast producer (e.g. `yes`, a verbose build, tmux full-screen @@ -1024,6 +1036,52 @@ function screenGate(screen: string): ChatPromptBlocked | null { return { blocked: 'screen', dialog: read.kind === 'dialog' ? read.text : null } } +/** + * A quick answer must reach the application's stdin, including when somebody scrolled the pane + * into copy mode. Keep every tmux command separate: control-mode replies are one block per + * command, so a command list would desynchronise the client's positional reply queue. + * Exported so the same delivery can be measured against a real tmux without a native PTY. + */ +export async function sendBackgroundTmuxKeys( + target: string, + data: string, + command: (args: string[]) => Promise<{ ok: boolean; body: string[] } | null>, + onUnconfirmed?: () => void, + expectedOwner?: PaneOwner +): Promise { + if (!data || !isSessionName(target)) return false + // A pane target needs the trailing colon. Without '=', a missing node can prefix-match a + // neighbouring session and report success after typing its answer into the wrong agent. + const selected = `=${target}:` + try { + const probe = await command(['display-message', '-p', '-t', selected, '#{pane_id} #{pane_in_mode}']) + const identity = probe?.body.length === 1 ? /^(%[0-9]+) ([01])$/.exec(probe.body[0]) : null + if (!probe?.ok || !identity) { + // An empty/unexpected control-mode reply may belong to the initial attach or another lost + // command. Retire that channel before a later answer can reuse its uncertain reply queue. + onUnconfirmed?.() + return false + } + // Pin the resolved pane across awaits: somebody selecting a different pane/window on the + // desktop between the probe and the answer must not redirect a phone's approval key. + const pane = identity[1] + if (expectedOwner) { + if (!expectedOwner.paneId || pane !== expectedOwner.paneId) return false + const current = await command(['display-message', '-p', '-t', pane, '#{pane_pid} #{pane_current_command}']) + if (!current?.ok || current.body.length !== 1 || current.body[0] !== `${expectedOwner.panePid} ${expectedOwner.command}`) return false + } + if (identity[2] === '1') { + if (!(await command(['send-keys', '-t', pane, '-X', 'cancel']))?.ok) return false + } + // Splitting is safe here: the validated target contains no whitespace and the payload was + // reduced to hexadecimal bytes by the production encoder. + return (await command(encodeSendKeysHex(pane, data).split(' ')))?.ok ?? false + } catch { + // A lost reply may follow a completed write. Never resend the keys on a different channel. + return false + } +} + /** * Manages all live PTY processes and bridges them to the renderer over IPC. * @@ -1219,7 +1277,7 @@ export class PtyManager { */ private released = new Map< string, - { sessionId: string; size?: PtySize; remote: boolean; sessionHost?: boolean } + { sessionId: string; size?: PtySize; remote: boolean; sessionHost?: boolean; sshRemote?: NonNullable } >() /** * The ONE control-mode client this manager keeps for background WRITES, plus the node whose tmux @@ -1237,10 +1295,15 @@ export class PtyManager { private shared: { client: ControlModeClient; persistKey: string } | null = null /** Disposes `shared` once no background write has needed it for `BACKGROUND_WRITE_LINGER_MS`. */ private sharedLinger: ReturnType | null = null + /** Serialize complete background deliveries per node: probe/cancel/send must keep stream chunks + * in arrival order. A slow pane must not hold up answers to unrelated nodes. */ + private backgroundWrites = new Map>() + private composedSubmissions = new Map>() /** The child-process seam for shadow clients. Undefined in production, where `ControlModeClient` * uses `child_process` (see tmux-control-client.ts); tests inject a fake spawner. */ private readonly controlSpawn: ControlSpawn | undefined private readonly confirmedProcessRun: ConfirmedProcessRun + private readonly managedLaunchRun: typeof runWithStdin /** Injectable only so Windows-only routing stays behavior-testable on every CI host. */ private readonly runtimePlatform: NodeJS.Platform @@ -1248,6 +1311,7 @@ export class PtyManager { deps: { controlSpawn?: ControlSpawn confirmedProcessRun?: ConfirmedProcessRun + managedLaunchRun?: typeof runWithStdin runtimePlatform?: NodeJS.Platform /** The Zellij binary (tests point this at a sandboxed release binary); absent = look it up. */ zellijBin?: string | null @@ -1256,6 +1320,7 @@ export class PtyManager { if (deps.zellijBin !== undefined) this.zellijPathMemo = deps.zellijBin this.controlSpawn = deps.controlSpawn this.confirmedProcessRun = deps.confirmedProcessRun ?? runAsync + this.managedLaunchRun = deps.managedLaunchRun ?? runWithStdin this.runtimePlatform = deps.runtimePlatform ?? os.platform() } @@ -1369,6 +1434,7 @@ export class PtyManager { sessionId, size: session.appliedSize, remote: !!session.sshRemote, + sshRemote: session.sshRemote, // Which backend still holds the session after this client goes. Agent messaging reaches a // released session by NAME, and must ask the backend that owns it (`sessionHostOwns`). sessionHost: !!session.sessionHost @@ -1594,9 +1660,8 @@ export class PtyManager { * Type `data` into a node whose PAINTER pty client is gone, without spawning one. * * The fallthrough, in order: - * 1. **the painter**, if the node is on screen after all — the session's own pty, exactly as a - * keystroke from its terminal. (A caller holding a stale session id can land here; it is the - * same NODE either way, which is what the caller asked for.) + * 1. **the live session**, if the node is on screen after all — a tmux side-call, so copy mode + * cannot swallow a quick answer. Native and session-host PTYs keep their direct write. * 2. **the node's own shadow**, if one is already up. Never attaches one: a shadow exists to be * re-used by whoever attached it, and a write does not need a session-scoped client. * 3. **the shared control client** (`sharedClientFor`) — one `tmux -C` child for the whole @@ -1613,15 +1678,80 @@ export class PtyManager { * is a name we have no claim to (a remote node's local orphan, another machine's idea of it, a * session someone else made). An unknown key is not evidence of a session. * - a node whose record says `remote`: its tmux is on the far host. Reaching it means the - * project's ControlMaster (`remoteTmuxPasteArgs`), not this channel; refusing is the honest - * answer until that exists. + * project's ControlMaster (`backgroundWriteOver`), never this local channel. */ async backgroundWrite(persistKey: string, data: string): Promise { + if (!data) return false + return this.serializeBackgroundWrite(persistKey, () => this.backgroundWriteNow(persistKey, data)).catch(() => false) + } + + private async serializeBackgroundWrite(persistKey: string, action: () => Promise): Promise { + const previous = this.backgroundWrites.get(persistKey) ?? Promise.resolve() + const writing = previous.then(action, action) + this.backgroundWrites.set(persistKey, writing) + try { + return await writing + } finally { + if (this.backgroundWrites.get(persistKey) === writing) this.backgroundWrites.delete(persistKey) + } + } + + /** Wake an existing owned generation, including an offscreen SSH pane over its retained master. */ + async wakeSleeping(request: SleepingWakeRequest): Promise { + if (!request || typeof request.nodeId !== 'string') return { delivered: false, verdict: 'invalid-request' } + return this.serializeBackgroundWrite(request.nodeId, async () => { + const live = this.liveSessionForPersistKey(request.nodeId) + const released = !live ? this.released.get(request.nodeId) : undefined + const remote = live?.sshRemote ?? released?.sshRemote + const owned = (): boolean => live + ? this.liveSessionForPersistKey(request.nodeId) === live + : !!released && !this.liveSessionForPersistKey(request.nodeId) && this.released.get(request.nodeId) === released && (!released.remote || !!remote) + return deliverSleepingWake(request, { + owned, + owner: async () => { + if (!remote) return this.paneOwner(request.nodeId) + const ssh = findSsh() + if (!ssh) return null + try { + const { stdout } = await runAsync(ssh, remotePaneOwnerCombinedArgs(remote.conn, remote.controlPath, sessionName(request.nodeId)), { timeout: PROBE_TIMEOUT_MS }) + return parseCombinedPaneOwner(stdout) + } catch { return null } + }, + write: async (data, owner) => { + if (!owned()) return false + if (remote) return this.backgroundWriteOver(request.nodeId, data, remote, owner) + if (live?.nativeWindowsPane) return live.nativeWindowsPane.wakeSleeping(data, owner) + if (this.sessionHostOwns(request.nodeId, live)) return sessionHostWakeSleeping(sessionName(request.nodeId), data, owner) + // An ordinary direct POSIX PTY has no persisted offscreen generation/owner adapter. + if (live && !live.persistKey) return false + return this.backgroundWriteNow(request.nodeId, data, owner) + }, + binaries: binariesFor(request.agentId, this.getSettings().customAgents), + platform: remote ? 'posix' : this.runtimePlatform + }) + }).catch(() => ({ delivered: false, verdict: 'delivery-failed' })) + } + + private async backgroundWriteNow(persistKey: string, data: string, expectedOwner?: PaneOwner): Promise { // Nothing to type — and `encodeSendKeysHex` would build a `send-keys -H ` with no bytes after // it, which is a command line worth not sending. if (!data) return false - const live = this.sessionByPersistKey(persistKey) + const live = this.liveSessionForPersistKey(persistKey) if (live) { + if (live.sshRemote) return expectedOwner + ? this.backgroundWriteOver(persistKey, data, live.sshRemote, expectedOwner) + : this.backgroundWriteOver(persistKey, data, live.sshRemote) + if (live.persistKey && !live.sessionHost) { + if (!this.tmuxPath) return false + const tmuxPath = this.tmuxPath + return sendBackgroundTmuxKeys(sessionName(persistKey), data, async (args) => { + const { stdout } = await runAsync(tmuxPath, ['-L', TMUX_SOCKET, ...args], { + encoding: 'utf-8', + timeout: PROBE_TIMEOUT_MS + }) + return { ok: true, body: stdout.trimEnd().split('\n') } + }, undefined, expectedOwner) + } try { live.proc.write(data) } catch { @@ -1639,10 +1769,8 @@ export class PtyManager { return run ? zellijWriteChars(run, sessionName(persistKey), data) : false } if (!this.tmuxPath || !settings.tmuxEnabled) return false - // The kill switch, and the reason it sits BELOW tier 1: the painter is the session's own pty, - // which exists with or without this feature — gating it would turn "no control clients" into - // "background writes stop working", which is a different setting. Below here, every tier needs - // a `tmux -C` child, so this one check covers both of them (`shadowAttach` carries the other). + // The kill switch sits BELOW tier 1: live writes need no control client. Below here, every tier + // needs a `tmux -C` child, so this check covers both of them (`shadowAttach` carries the other). if (!settings.ptyShadowClients) return false const known = this.released.get(persistKey) if (!known || known.remote) return false @@ -1651,17 +1779,34 @@ export class PtyManager { // 12), and this line reaches a tmux server holding every session on the socket. `sessionName` // cannot produce anything else today — which is exactly why this stays cheap. if (!isSessionName(target)) return false - const line = encodeSendKeysHex(target, data) + const line = (args: string[]): string => + args.map((arg) => (arg.startsWith('#{') ? `'${arg}'` : arg)) + .join(' ') const shadow = this.shadows.get(persistKey) // ALIVE, not merely present: `dispose()` is silent (it fires no `onExit`), so a shadow retired // by whoever was handed it leaves its entry behind, and a dead client can deliver nothing. // Falling through to tier 3 does not violate the never-retry rule either — a client that is not // running rejects `command()` BEFORE writing a byte (tmux-control-client.ts), so the keys it // refused cannot also have reached tmux. - if (shadow?.alive) return (await this.shadowCommand(persistKey, line))?.ok ?? false + if (shadow?.alive) + return sendBackgroundTmuxKeys( + target, + data, + (args) => this.shadowCommand(persistKey, line(args)), + () => { + if (this.shadows.get(persistKey) === shadow) this.shadowDispose(persistKey) + }, + expectedOwner + ) const client = this.sharedClientFor(persistKey) if (!client) return false - return (await this.controlCommand(client, line, () => this.sharedDispose(client)))?.ok ?? false + return sendBackgroundTmuxKeys( + target, + data, + (args) => this.controlCommand(client, line(args), () => this.sharedDispose(client)), + () => this.sharedDispose(client), + expectedOwner + ) } /** @@ -2060,6 +2205,7 @@ export class PtyManager { platform().handle(IPC.ptySendText, (persistKey: string, text: string, enter?: boolean) => this.sendText(persistKey, text, enter === undefined ? undefined : { enter }) ) + platform().handle(IPC.ptyWakeSleeping, (request: SleepingWakeRequest) => this.wakeSleeping(request)) platform().handle(IPC.ptySendChatPrompt, (persistKey: string, text: string, agentId: unknown) => // `agentId` crosses a process boundary: it only picks the screen reader, and a non-string // picks none (plain `sendText`), never a wrong one. @@ -2304,6 +2450,117 @@ export class PtyManager { if (live) this.kill(HEADLESS_CLIENT, live[0]) } + private readonly managedPanes = new Map() + + supportsManagedCreation(): boolean { + return (process.platform === 'linux' || process.platform === 'darwin') && + !!this.tmuxPath && this.getSettings().tmuxEnabled + } + + /** A host-owned New operation may only create. It cannot adopt a raced warm generation. */ + async createManagedHeadless(options: PtyCreateOptions, creationId: string, current: () => boolean): Promise { + const key = options.persistKey + const cwdIdentity = options.cwd ? fs.statSync(options.cwd) : undefined + if (!current() || !this.supportsManagedCreation() || !MANAGED_CREATION_UUID.test(creationId) || + !key || !/^term-[a-z0-9]+-[a-z0-9]{1,16}$/.test(key) || !options.ownerProjectId || + !options.cwd || !cwdIdentity?.isDirectory() || options.sshRemote || options.requireRemote || + this.liveSessionForPersistKey(key) || this.inflight.has(key) || this.managedPanes.has(creationId)) + throw new Error('This host cannot create that managed terminal') + const spawn = this.spawnNew(0, options, { creationId, current, cwdIdentity }) + this.inflight.set(key, spawn) + let sessionId: string | undefined + let createdSession: Session | undefined + try { + const result = await spawn + sessionId = result.sessionId + const session = this.sessions.get(sessionId) + createdSession = session + if (!session || !result.fresh || !result.persistent || result.accountFallback || !current()) + throw new Error('Managed terminal creation could not be confirmed') + // Wait for the actual new-session command to reach tmux, never infer readiness from a PTY id. + const deadline = Date.now() + 2500 + let receipt: ManagedPaneReceipt | undefined + while (Date.now() < deadline && this.sessions.get(sessionId) === session && current()) { + receipt = await this.readManagedPane(options, creationId).catch(() => undefined) + if (receipt) break + await new Promise((resolve) => setTimeout(resolve, 25)) + } + if (!receipt || !current() || this.sessions.get(sessionId) !== session) + throw new Error('Managed terminal creation could not be confirmed') + this.managedPanes.set(creationId, { receipt, sessionId, session, attempted: false }) + recordFreshSpawnOwner(key, options.ownerProjectId) + return receipt + } catch (error) { + // Detach only OUR synthetic client. Never kill a same-name backend on an uncertain create. + if (sessionId && createdSession && this.sessions.get(sessionId) === createdSession) this.kill(0, sessionId) + throw error + } finally { + if (this.inflight.get(key) === spawn) this.inflight.delete(key) + } + } + + private async readManagedPane(options: Pick, creationId: string): Promise { + if (!this.tmuxPath || !options.persistKey || !options.ownerProjectId) throw new Error('Managed pane unavailable') + const session = sessionName(options.persistKey) + const args = ['-L', TMUX_SOCKET, 'list-panes', '-s', '-t', `=${session}`, '-F', MANAGED_PANE_FORMAT] + const { stdout } = await runAsync(this.tmuxPath, args, { timeout: 1500 }) + const identity = parseManagedPane(stdout, { creationId, session }) + if (!identity) throw new Error('Managed pane identity changed') + const paneBirth = await readManagedProcessBirth(identity.panePid) + const after = await runAsync(this.tmuxPath, args, { timeout: 1500 }) + if (after.stdout !== stdout) throw new Error('Managed pane identity changed') + return { version: 1, creationId, nodeId: options.persistKey, projectId: options.ownerProjectId, + socket: TMUX_SOCKET, session, ...identity, paneBirth } + } + + /** Read-only confirmation requires this process's live generation, never just saved markers. */ + async verifyManagedPane(receipt: ManagedPaneReceipt, current: () => boolean): Promise { + const held = this.managedPanes.get(receipt.creationId) + if (!held || !sameManagedPane(held.receipt, receipt) || !current()) return false + const valid = (): boolean => current() && this.sessions.get(held.sessionId) === held.session && + this.liveSessionForPersistKey(receipt.nodeId) === held.session + if (!valid()) return false + try { + const actual = await this.readManagedPane({ persistKey: receipt.nodeId, ownerProjectId: receipt.projectId }, receipt.creationId) + return sameManagedPane(actual, receipt) && valid() + } catch { return false } + } + + /** Consumes the runtime launch permit before any await. A lost write result is never replayed. */ + async deliverManagedLaunch(receipt: ManagedPaneReceipt, command: string, current: () => boolean): Promise { + const held = this.managedPanes.get(receipt.creationId) + if (!held || held.attempted || !sameManagedPane(held.receipt, receipt) || !current()) return false + held.attempted = true + const valid = (): boolean => current() && this.sessions.get(held.sessionId) === held.session && + this.liveSessionForPersistKey(receipt.nodeId) === held.session + try { + const deadline = Date.now() + 2500 + let ready = false + while (Date.now() < deadline && valid()) { + const owner = await this.paneOwner(receipt.nodeId) + if (owner?.paneId === receipt.paneId && owner.panePid === receipt.panePid && + owner.pids?.length === 1 && owner.pids[0] === receipt.panePid && isShellCommand(owner.command)) { + ready = true + break + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + if (!ready || !valid()) return false + const actual = await this.readManagedPane({ persistKey: receipt.nodeId, ownerProjectId: receipt.projectId }, receipt.creationId) + if (!sameManagedPane(actual, receipt) || !valid() || !this.tmuxPath) return false + const plan = managedLaunchPlan(receipt, command) + try { + const result = await this.managedLaunchRun(this.tmuxPath, plan.args, plan.body) as { stdout?: string } + return result.stdout?.trim() === 'nt-managed-delivered' && valid() + } catch { + void runAsync(this.tmuxPath, plan.cleanup).catch(() => {}) + return false + } + } catch { return false } + } + private async create(clientId: ClientId, options: PtyCreateOptions): Promise { const key = options.persistKey if (!key) return this.spawnNew(clientId, options) @@ -2583,7 +2840,7 @@ export class PtyManager { } /** Spawn a brand-new session for this client (the non-co-attach path). */ - private async spawnNew(clientId: ClientId, options: PtyCreateOptions): Promise { + private async spawnNew(clientId: ClientId, options: PtyCreateOptions, exclusive?: { creationId: string; current: () => boolean; cwdIdentity?: Pick }): Promise { const refused = this.spawnRefusal(options) if (refused) return { sessionId: '', fresh: false, unavailable: refused } // A tmux-backed session is "fresh" (cold start) when no live session exists to reattach to @@ -2644,6 +2901,7 @@ export class PtyManager { // session that exists"). Asked only for a LOCAL persistent node whose tmux session is not there: // a warm tmux session is reattached exactly as before, and SSH projects keep the remote tmux. const zellijChoice = + !exclusive && !options.sshRemote && options.persistKey && !warmWindowsBackend && @@ -2690,6 +2948,8 @@ export class PtyManager { if (!windowSize) return { sessionId: '', fresh: false, unavailable: 'join-only' } options = { ...options, cols: windowSize.cols, rows: windowSize.rows } } + if (exclusive && (!fresh || !exclusive.current() || !this.supportsManagedCreation())) + throw new Error('Managed terminal name already exists or creation is no longer current') // Ensure the login-shell PATH is resolved (prewarmed in init(); usually already settled) // so the session env below picks it up — awaiting keeps the event loop free either way. await resolveShellPath() @@ -2739,12 +2999,16 @@ export class PtyManager { : null let sessionId: string try { + if (exclusive && (!exclusive.current() || !this.supportsManagedCreation() || this.liveSessionForPersistKey(options.persistKey as string))) + throw new Error('Managed terminal creation is no longer current') sessionId = this.spawnSession( options, clientId, undefined, warmWindowsBackend, projectOverrides, + exclusive?.creationId, + exclusive?.cwdIdentity, zellijChoice?.use === true ) } catch (err) { @@ -2760,7 +3024,7 @@ export class PtyManager { // someone else spawned (incl. an app-restart re-attach) leaves the pane UNPROVEN, so a second // project that merely opens another's node id cannot claim it. The owner is the renderer's // machine-local project id, never the git-shared file id. See `agents/pane-ownership.ts`. - if (shouldRecordOwnership(fresh, options.persistKey, options.ownerProjectId)) + if (!exclusive && shouldRecordOwnership(fresh, options.persistKey, options.ownerProjectId)) recordFreshSpawnOwner(options.persistKey as string, options.ownerProjectId) if (warmWindowsBackend === 'tmux') { // The first strict probe deliberately preceded profile resolution. Recheck after launching @@ -2945,6 +3209,76 @@ export class PtyManager { ) } + /** + * `sessionExists` for a node on an SSH project's host — the relay host's `pty.attach` asks it + * before attaching over the master (audit A09). Same fold as `create()`: only tmux's own "no such + * session" is absence; an unreadable host answers "exists", so nothing cold-restores a live pane. + */ + async sessionExistsOver( + persistKey: string, + sshRemote: NonNullable + ): Promise { + return this.remoteSessionExists(sshRemote, sessionName(persistKey)) + } + + /** `captureSnapshot` for a node on an SSH project's host: its visible pane, with colours. */ + async captureSnapshotOver( + persistKey: string, + sshRemote: NonNullable + ): Promise { + const ssh = findSsh() + if (!ssh) return '' + try { + const { stdout } = await runAsync( + ssh, + remoteCaptureScreenArgs(sshRemote.conn, sshRemote.controlPath, sessionName(persistKey)), + { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024, timeout: PROBE_TIMEOUT_MS } + ) + return stdout + } catch { + return '' + } + } + + /** + * `backgroundWrite` for a node on an SSH project's host: type a few keys into its REMOTE tmux + * pane over the project's ControlMaster, without attaching anything (`remoteTmuxSendKeysArgs`). + * The relay host's `node.sendKeys` asks it with the `sshRemote` its `HostRemoteNodes` resolver + * produced — the same one `pty.attach` uses for such a node (audit A09) — so a quick answer from + * the phone reaches a node of an SSH project in one tap instead of answering `sent:false`. + * + * Never the local socket, by construction: there is no branch here that names one. A + * `backgroundWrite` of the same node is refused for the same reason (its tmux is on the far host). + * + * `true` only when the remote `send-keys` exited 0, i.e. the session existed (the target is exact) + * and the keys were handed to its pane. Every failure is `false` and is never retried — a timed-out + * ssh may well have run the command, and typing an answer twice is worse than not typing it — the + * same rule `backgroundWrite` keeps. No ssh binary, an unsafe name, a master that is gone, a host + * tmux too old for `send-keys -H`, a session that does not exist: all `false`. + */ + async backgroundWriteOver( + persistKey: string, + data: string, + sshRemote: NonNullable, + expectedOwner?: PaneOwner + ): Promise { + if (!data) return false + const ssh = findSsh() + if (!ssh) return false + let args: string[] + try { + args = remoteTmuxSendKeysArgs(sshRemote.conn, sshRemote.controlPath, sessionName(persistKey), data, expectedOwner) + } catch { + return false + } + try { + await runAsync(ssh, args, { timeout: PROBE_TIMEOUT_MS }) + return true + } catch { + return false + } + } + /** One coalesced remote `tmux list-sessions` per ControlMaster (see remote-session-index.ts). * `list` carries the SAME classification the per-node probe had: tmux's own exit 1 ("no server * running") is the only evidence of absence; ssh 255 / 127 / a timeout answer `unknown`, which @@ -3359,7 +3693,69 @@ export class PtyManager { sinks: DetachedSinks, options: Omit = { cols: 80, rows: 24 } ): string { - return this.spawnSession({ ...options, persistKey }, null, sinks) + // PANE OWNERSHIP for a session the relay host CREATES (audit A72) — the counterpart of the + // `spawnNew` record, for the one spawn path that never goes through it. `ownerProjectId` has a + // single caller: the relay host's `pty.attach`, which passes it only when (a) this manager's + // own `sessionExists` probe (fail-safe toward "exists") found no session, so this attach is the + // one creating it, and (b) the owner came from the host's own index (`HostNewSessions` — the + // entry id the canvas passes as ITS `ownerProjectId`), never off the wire. One more refusal is + // answered here, from this process's own state: a live generation of this node means the + // attach is a JOIN whatever the caller probed, and a join never records + // (`agents/pane-ownership.ts`). No owner — every join, every older phone — records nothing. + const joining = !!this.liveSessionForPersistKey(persistKey) + const sessionId = this.spawnSession({ ...options, persistKey }, null, sinks) + if (shouldRecordOwnership(!joining, persistKey, options.ownerProjectId)) + recordFreshSpawnOwner(persistKey, options.ownerProjectId) + return sessionId + } + + /** Prepare a relay attach without spawning anything. A cold pane gets the SAME trust-aware + * project reader as create(); live panes neither prompt nor change their launch facts. The host + * commits after SnapshotEnd in one turn, so the phone's first launch line cannot be dropped while + * an asynchronous settings read is pending. Discarding this result has no PTY side effects. */ + async prepareDetachedAttach( + persistKey: string, + options: Omit = { cols: 80, rows: 24 } + ): Promise<{ readonly fresh: boolean; attach(sinks: DetachedSinks): string }> { + const exists = async (): Promise => { + let timer: ReturnType | undefined + try { + return await Promise.race([ + (options.sshRemote + ? this.sessionExistsOver(persistKey, options.sshRemote) + : this.sessionExists(persistKey)).catch(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(true), 750) + timer.unref?.() + }) + ]) + } finally { + if (timer) clearTimeout(timer) + } + } + let cold = !(await exists()) + const overrides = cold && !options.sshRemote + ? await this.projectSpawnOverrides({ ...options, persistKey }) + : null + // Another desktop/phone may have created the backend pane while trust/settings were read. + // Reprobe the backend too: a warm tmux pane need not have a live wrapper in this manager. + if (cold) cold = !(await exists()) + const fresh = (): boolean => cold && !this.liveSessionForPersistKey(persistKey) + return { + get fresh() { return fresh() }, + attach: (sinks) => { + const creating = fresh() + const launch: Omit = creating ? options : { + cols: options.cols, rows: options.rows, + ...(options.sshRemote ? { sshRemote: options.sshRemote } : {}), + ...(options.requireRemote ? { requireRemote: true } : {}) + } + const id = this.spawnSession({ ...launch, persistKey }, null, sinks, undefined, creating ? overrides : null) + if (shouldRecordOwnership(creating, persistKey, launch.ownerProjectId)) + recordFreshSpawnOwner(persistKey, launch.ownerProjectId) + return id + } + } } /** @@ -3393,7 +3789,11 @@ export class PtyManager { async prepareRelayAttach( nodeId: string, size: { cols: number; rows: number }, - hint: { projectId?: string } = {} + hint: { + projectId?: string + /** Host-resolved creation facts for an unregistered local node, never phone-supplied. */ + create?: Pick + } = {} ): Promise { const resolver = this.relayNodes const placements = resolver?.placements(nodeId) ?? [] @@ -3425,6 +3825,7 @@ export class PtyManager { }) if (plan.kind === 'refuse') return { kind: 'refused', reason: plan.reason, message: plan.message } const options: PtyCreateOptions = { + ...(plan.kind === 'local' && placements.length === 0 && !liveRemote ? hint.create : {}), ...plan.options, persistKey: nodeId, cols: size.cols, @@ -3491,16 +3892,17 @@ export class PtyManager { attach: (sinks) => this.spawnRelayRemote(options, sinks) } } + // Resolve cold-create trust and settings before SnapshotEnd. The commit below has no + // asynchronous gap in which the phone's first launch line could arrive without a PTY. + await resolveShellPath() + const prepared = await this.prepareDetachedAttach(nodeId, options) return { kind: 'ready', remote: false, + get fresh() { return prepared.fresh }, sessionExists: () => this.sessionExists(nodeId), snapshot: () => this.captureSnapshot(nodeId), - attach: async (sinks) => { - await resolveShellPath() - const overrides = await this.projectSpawnOverrides(options) - return this.spawnSession(options, null, sinks, undefined, overrides) - } + attach: async (sinks) => prepared.attach(sinks) } } @@ -3623,6 +4025,8 @@ export class PtyManager { /** What the OWNING project contributes (see `projectSpawnOverrides`) — already resolved, * because this function is synchronous. Null on every path with no proven project owner. */ overrides?: ProjectSpawnOverrides | null, + managedCreationId?: string, + managedCwdIdentity?: Pick, /** `create()`'s Zellij decision. Absent on the detached (relay host) paths, which fall back to * what this process knows about the node (`zellijKeys`) — a phone attaching to a Zellij node * must join that session, never create a tmux one beside it. */ @@ -3682,8 +4086,7 @@ export class PtyManager { } // SWAP-OUT, before anything at all is spawned: a painter pty client is arriving for this node, - // and a session never has both. The painter attaches with `-D` and would kick the shadow off by - // itself — but only once tmux has processed both attaches, leaving a window where two clients + // and a session never has both. Explicit retirement avoids a window where two clients // of ours negotiate one pane. Retiring it first (politely: `dispose()` sends `detach-client`) // means exactly one client is ever attached, and because `dispose()` is silent this can never // be mistaken for a shadow that died and wants re-attaching. @@ -3717,8 +4120,12 @@ export class PtyManager { // directory actually exists and fall back to home if not, so a dead folder never kills the node. if (!options.sshRemote) { try { - if (!fs.statSync(cwd).isDirectory()) cwd = os.homedir() + const stat = fs.statSync(cwd) + if (!stat.isDirectory() || (managedCwdIdentity && + (stat.dev !== managedCwdIdentity.dev || stat.ino !== managedCwdIdentity.ino))) + throw new Error('The project folder changed before managed creation') } catch { + if (managedCreationId) throw new Error('The project folder changed before managed creation') cwd = os.homedir() } } @@ -3829,12 +4236,35 @@ export class PtyManager { // Remote (ssh) sessions get their account env via the remote tmux `-e` list instead // (the local ssh client process doesn't need it). let accountFallback = false + if (managedCreationId && options.accountId) { + const settings = this.getSettings() + const accounts = options.agentId === 'claude' ? settings.claudeAccounts : + options.agentId === 'codex' ? settings.codexAccounts : [] + const matching = accounts.filter((account) => account.id === options.accountId) + const account = matching[0] + if (matching.length !== 1 || account.host || account.pending) + throw new Error('The selected managed account is no longer available') + if (options.agentId === 'claude' && 'configDir' in account && account.configDir !== undefined) { + const linked = normalizeLinkedConfigDir(account.configDir) + if (!linked || claudeConfigDirFor(options.accountId) !== linked) + throw new Error('The selected linked account is no longer available') + } + if (options.agentId === 'codex' && isCodexScopeRefusal(resolveCodexSessionScope(platform().userDataDir, options.accountId))) + throw new Error('The selected managed account is no longer available') + } let accountDir = - options.accountId && !options.sshRemote ? claudeConfigDirFor(options.accountId) : null + options.accountId && !options.sshRemote && (!managedCreationId || options.agentId === 'claude') + ? claudeConfigDirFor(options.accountId) : null + if (managedCreationId && accountDir) { + try { + if (!fs.statSync(accountDir).isDirectory()) throw new Error('Not a directory') + } catch { throw new Error('The selected managed account is no longer available') } + } // Missing/deleted account dir (spec: error handling) → fall back to system default // instead of pointing claude at a dead dir; the node then behaves like an unbound one. // `accountFallback` is surfaced to the renderer (warning chip) via the create() result. if (accountDir && !fs.existsSync(accountDir)) { + if (managedCreationId) throw new Error('The selected managed account is no longer available') console.warn(`[accounts] config dir missing for ${options.accountId}, using system default`) accountDir = null accountFallback = true @@ -4163,13 +4593,8 @@ export class PtyManager { this.zellijKeys.add(options.persistKey) } else if (this.tmuxPath && settings.tmuxEnabled && options.persistKey) { // attach-or-create the persistent session for this node. - // `-A` = attach-or-create. `-D` = detach OTHER clients on attach. We use `-D` ONLY for the - // local renderer client (a remount should take sole ownership of its session). A host-served - // PTY (sinks set) MUST NOT detach others: the host's own local client is attached to the same - // `nt-` session, and a connecting client should MIRROR it (tmux co-attach), not kick it - // off — `-D` there is exactly what showed "[detached]" in every host window on connect. A - // join-only (watch-only) reattach mirrors for the same reason: see `tmuxAttachFlags`. - // (tmux sizes a co-attached session to the smallest client — the accepted mirroring tradeoff.) + // Never broadly detach external viewers. After attachment, tmux-painter can replace a + // positively owned prior app painter without affecting direct SSH or relay phones (A13). // `-e` sets the session environment explicitly (the tmux server is shared, so relying // on the client's inherited env would leak the first session's values into later ones). file = this.tmuxPath @@ -4184,8 +4609,6 @@ export class PtyManager { '-f', this.confPath, 'attach-session', - // `-d` detaches other clients, so it follows `tmuxAttachFlags`' mirror rule. - ...(sinks || options.joinOnly ? [] : ['-d']), '-t', sessionName(options.persistKey) ] @@ -4230,7 +4653,7 @@ export class PtyManager { ...Object.keys(customEnvMerged), ...Object.keys(projectEnv ?? {}) ]) - const attachFlags = tmuxAttachFlags(!!sinks || !!options.joinOnly) + const attachFlags = managedCreationId ? managedCreateFlags(managedCreationId) : tmuxAttachFlags(!!sinks || !!options.joinOnly) args = [ '-L', TMUX_SOCKET, @@ -4415,6 +4838,13 @@ export class PtyManager { this.ensureReapTimer() } this.sessions.set(sessionId, session) + if (useLocalTmux && sinks && options.persistKey && this.tmuxPath) { + const tmux = this.tmuxPath + session.composedViewer = captureComposedViewer(TMUX_SOCKET, sessionName(options.persistKey), proc.pid, { + current: () => this.sessions.get(sessionId) === session, + run: async (args) => (await runAsync(tmux, args, { timeout: 1000, maxBuffer: 64 * 1024, encoding: 'utf8' })).stdout + }) + } // Index by node id even when the session is NOT tmux-persisted (`persisted` only governs // scrollback snapshots): co-attach must work for a plain-shell session too. Detached // (relay-served) ptys are deliberately NOT indexed — the relay path keeps its own session, @@ -4502,6 +4932,13 @@ export class PtyManager { }) } + if (useLocalTmux && !sinks && options.persistKey && this.tmuxPath) { + session.closeTmuxPainter = trackTmuxPainter({ + userData: platform().userDataDir, tmux: this.tmuxPath, socket: TMUX_SOCKET, + session: sessionName(options.persistKey), pid: proc.pid, + current: () => this.sessions.get(sessionId) === session + }).close + } return sessionId } @@ -4569,7 +5006,11 @@ export class PtyManager { /** Drop a dead/released session from both indexes. Keyed off `indexKey` (not `persistKey`, * which is only set for tmux-PERSISTED sessions) so a plain-shell node is un-indexed too. */ private forget(sessionId: string, session: Session): void { + session.closeTmuxPainter?.() session.nativeWindowsPane?.dispose() + for (const [creationId, held] of this.managedPanes) { + if (held.sessionId === sessionId && held.session === session) this.managedPanes.delete(creationId) + } this.sessions.delete(sessionId) if (session.indexKey && this.byPersistKey.get(session.indexKey) === sessionId) this.byPersistKey.delete(session.indexKey) @@ -4897,6 +5338,71 @@ export class PtyManager { session.proc.write(data) } + /** Scroll only this captured live viewer; native history never falls through to raw input. */ + async scrollAttached(clientId: ClientId | null, sessionId: string, up: boolean, lines: number, capture: boolean, + current: () => boolean): Promise { + const session = this.sessions.get(sessionId) + const refused = (): NativeScrollResult => ({ status: 'refused', message: 'This terminal viewer is no longer attached or cannot provide safe history scrolling.' }) + const uncertain = (): NativeScrollResult => ({ status: 'uncertain', message: 'Wheel input may have reached the terminal. It was not sent again.' }) + const valid = (): boolean => !!session && current() && this.sessions.get(sessionId) === session + if (!session || !valid() || typeof up !== 'boolean' || typeof capture !== 'boolean' || + !Number.isInteger(lines) || lines < 1 || lines > 20) return refused() + if (session.nativeWindowsPane) return session.nativeWindowsPane.scrollForHistory(up, lines, capture, valid) + if (session.sessionHost) { + const proc = session.proc as unknown as SessionHostPty + return typeof proc.scrollForHistory === 'function' ? proc.scrollForHistory(up, lines, capture, valid) : refused() + } + if (!session.tmuxBacked || !session.persistKey) return refused() + let attempted = false + try { + const data = `\x1b[<${up ? 64 : 65};1;1M` + for (let i = 0; i < lines; i++) { + if (!valid()) return attempted ? uncertain() : refused() + if (!attempted && clientId !== null && session.nodeId && presenceHub.peerCount() > 1) + presenceHub.noteTyping(clientId, session.nodeId) + attempted = true + session.proc.write(data) + } + return valid() ? { status: 'input' } : uncertain() + } catch { return attempted ? uncertain() : refused() } + } + + /** Explicit composer action on this captured viewer only. Raw input never enters this path. */ + async submitComposed(sessionId: string, value: ComposedInput, current: () => boolean): Promise { + const input = parseComposedInput(value) + const session = this.sessions.get(sessionId) + if (!input) return { status: 'refused', message: 'Invalid composed terminal input.' } + if (!session || !current()) return { status: 'refused', message: 'This terminal is no longer attached.' } + if (session.sshRemote || (!session.sessionHost && !session.nativeWindowsPane && (!session.composedViewer || !this.tmuxPath))) + return { status: 'refused', message: COMPOSED_INPUT_UNSUPPORTED } + const valid = (): boolean => current() && this.sessions.get(sessionId) === session + const previous = this.composedSubmissions.get(sessionId) ?? Promise.resolve() + const writing = previous.then(async (): Promise => { + if (!valid()) return { status: 'refused', message: 'This terminal viewer changed. Reattach before sending.' } + if (session.nativeWindowsPane) return session.nativeWindowsPane.submitComposed(input, valid) + if (session.sessionHost) { + const proc = session.proc as unknown as SessionHostPty + return typeof proc.submitComposed === 'function' ? proc.submitComposed(input, valid) + : { status: 'refused', message: COMPOSED_INPUT_UNSUPPORTED } + } + const viewer = await session.composedViewer + if (!viewer || !valid()) return { status: 'refused', message: 'This terminal viewer changed. Reattach before sending.' } + const tmux = this.tmuxPath! + return submitComposedTmux(viewer, input, { + current: valid, + run: async (args, body) => { + if (body === undefined) return (await runAsync(tmux, args, { timeout: 1000, maxBuffer: 64 * 1024, encoding: 'utf8' })).stdout + const result = await runWithStdin(tmux, args, body) as { stdout: string } + return result.stdout + } + }) + }) + this.composedSubmissions.set(sessionId, writing) + try { return await writing } finally { + if (this.composedSubmissions.get(sessionId) === writing) this.composedSubmissions.delete(sessionId) + } + } + /** * The `write()` miss: no live session answers to this id. It may still be a session THIS process * released — the pty client detached, the tmux session (and everything running in it) untouched — @@ -5117,6 +5623,34 @@ export class PtyManager { } } + /** Search the actual attached generation, never an untrusted caller-selected pane/path. */ + async historySearch(sessionId: string, query: string): Promise { + const live = this.sessions.get(sessionId) + if (!live || (!live.persistKey && !live.nativeWindowsPane)) throw new Error('This terminal is no longer attached.') + const key = live.persistKey ?? live.nodeId! + let text: string + if (live.nativeWindowsPane) text = await live.nativeWindowsPane.historyText() + else if (live.sshRemote) { + const ssh = findSsh() + if (!ssh) throw new Error('SSH is unavailable on this host.') + text = (await runAsync(ssh, remoteHistoryCaptureArgs(live.sshRemote.conn, live.sshRemote.controlPath, sessionName(key)), + { encoding: 'utf8', maxBuffer: HISTORY_MAX_BYTES, timeout: 20_000 })).stdout + } else if (live.sessionHost || !this.tmuxPath) { + const result = await sessionHostHistorySearch(sessionName(key), query) + if (this.sessions.get(sessionId) !== live) throw new Error('The terminal changed while its history was captured.') + return result + } else { + const args = ['-L', TMUX_SOCKET] + const pane = (await runAsync(this.tmuxPath, [...args, 'display-message', '-p', '-t', `=${sessionName(key)}:`, '#{pane_id}'], + { encoding: 'utf8', timeout: 6_000 })).stdout.trim() + if (!/^%[0-9]+$/.test(pane)) throw new Error('The terminal pane could not be resolved.') + text = (await runAsync(this.tmuxPath, [...args, 'capture-pane', '-p', '-J', '-t', pane, '-S', '-'], + { encoding: 'utf8', maxBuffer: HISTORY_MAX_BYTES, timeout: 20_000 })).stdout + } + if (this.sessions.get(sessionId) !== live) throw new Error('The terminal changed while its history was captured.') + return searchTerminalHistory(text, query) + } + /** * The canvas node a live session runs (its unconditional `nodeId`, see `Session`), or undefined * when the session is unknown: never created, or already ended. Read-only. The hosted team relay @@ -7050,6 +7584,7 @@ export class PtyManager { } const finals: Promise[] = [] for (const session of this.sessions.values()) { + session.closeTmuxPainter?.() if (session.flushTimer) clearTimeout(session.flushTimer) // Final scrollback snapshot on quit so a reboot can replay it. Skipped for sessions with // no output since the last periodic capture (unchanged pane content) — but NOT for one whose @@ -7069,6 +7604,7 @@ export class PtyManager { this.released.clear() this.sessions.clear() this.byPersistKey.clear() + this.managedPanes.clear() // Pending recycle notices die with the sessions they were waiting on (their timers would // otherwise fire into a manager that has released everything). for (const entry of this.pendingRecycle.values()) { diff --git a/src/core/pty-project-settings.test.ts b/src/core/pty-project-settings.test.ts index 66db141e4e..9576467201 100644 --- a/src/core/pty-project-settings.test.ts +++ b/src/core/pty-project-settings.test.ts @@ -20,7 +20,8 @@ import { MODEL_GATEWAY_ENV_KEYS } from '../shared/agents/model-gateway' import { setCustomAgentBaseResolver } from '../shared/agents/config' import { remoteCodexHome } from './codex-accounts-core' import { hookServer } from './agents/hook-server' -import type { ProjectSpawnOverrides } from './project-spawn-overrides' +import { makeProjectSpawnOverrides, type ProjectSpawnOverrides } from './project-spawn-overrides' +import { paneOwnerProject, resetPaneOwnershipForTests } from './agents/pane-ownership' interface SpawnCall { file: string @@ -113,6 +114,7 @@ const staged: Array<{ remotePath: string; content: string }> = [] describe('project settings at the spawn — LOCAL leg', () => { let fake: FakePlatform beforeEach(() => { + resetPaneOwnershipForTests() spawns.length = 0 staged.length = 0 fake = fakePlatform() @@ -129,6 +131,109 @@ describe('project settings at the spawn — LOCAL leg', () => { const create = async (options: Record): Promise => fake.handlers[IPC.ptyCreate](1, { cols: 80, rows: 24, ...options }) + const sinks = { onData: () => {}, onExit: () => {} } + const detached = { cols: 80, rows: 24, ownerProjectId: PROJECT, agentId: 'claude' as const } + + it('prepares a cold detached pane without spawning, then applies project env/shell and records its owner', async () => { + const read = vi.fn(async () => ({ env: { PROJECT_TOKEN: 'detached', NODETERM_NODE_ID: 'forged' }, shell: '/bin/fish' })) + vi.spyOn(hookServer, 'buildPtyEnv').mockReturnValue({ NODETERM_NODE_ID: NODE }) + const mgr = await manager(read) + const prepared = await mgr.prepareDetachedAttach(NODE, detached) + expect(spawns).toHaveLength(0) + expect(paneOwnerProject(NODE)).toBeUndefined() + expect(prepared.fresh).toBe(true) + prepared.attach(sinks) + expect(read).toHaveBeenCalledWith(PROJECT) + expect(spawns[0].file).toBe('/bin/fish') + expect(spawns[0].env.PROJECT_TOKEN).toBe('detached') + expect(spawns[0].env.NODETERM_NODE_ID).toBe(NODE) + expect(paneOwnerProject(NODE)).toBe(PROJECT) + }) + + it('keeps the real shared-settings trust gate on cold detached env and shell', async () => { + const requested: string[] = [] + const reader = makeProjectSpawnOverrides({ + readSettings: async () => ({ local: undefined, shared: { version: 1, rev: 1, savedAt: '', + agents: { env: { UNTRUSTED: 'never' } }, terminal: { shell: '/bin/fish' } } }), + targetInfo: () => ({ cwd: '/repo', name: 'repo' }), + trust: { isTrusted: async () => false }, + requestTrust: (_, family) => requested.push(family) + }) + const mgr = await manager(reader) + ;(await mgr.prepareDetachedAttach(NODE, detached)).attach(sinks) + expect(spawns[0].env.UNTRUSTED).toBeUndefined() + expect(spawns[0].file).not.toBe('/bin/fish') + expect(requested).toEqual(['agents', 'shell']) + }) + + it('joins a warm detached pane without reading settings or assigning a new owner', async () => { + const read = vi.fn(async () => ({ env: { PROJECT_TOKEN: 'changed' }, shell: '/bin/fish' })) + const mgr = await manager(read, { tmux: '/usr/bin/tmux' }) + mgr.attachDetached(NODE, sinks, { cols: 80, rows: 24 }) + const prepared = await mgr.prepareDetachedAttach(NODE, detached) + expect(prepared.fresh).toBe(false) + prepared.attach(sinks) + expect(read).not.toHaveBeenCalled() + expect(spawns[1].env.PROJECT_TOKEN).toBeUndefined() + expect(spawns[1].file).not.toBe('/bin/fish') + expect(paneOwnerProject(NODE)).toBeUndefined() + }) + + it('reprobes externally warm panes after the settings await and drops cold launch facts', async () => { + const mgr = await manager(async () => ({ env: { PROJECT_TOKEN: 'changed' }, shell: '/bin/fish' })) + vi.spyOn(mgr, 'sessionExists').mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const prepared = await mgr.prepareDetachedAttach(NODE, detached) + expect(prepared.fresh).toBe(false) + prepared.attach(sinks) + expect(spawns[0].env.PROJECT_TOKEN).toBeUndefined() + expect(spawns[0].file).not.toBe('/bin/fish') + expect(paneOwnerProject(NODE)).toBeUndefined() + }) + + it('also notices a new local live generation between preparation and commit', async () => { + const mgr = await manager(async () => ({ env: { PROJECT_TOKEN: 'changed' }, shell: '/bin/fish' }), { tmux: '/usr/bin/tmux' }) + vi.spyOn(mgr, 'sessionExists').mockResolvedValue(false) + const prepared = await mgr.prepareDetachedAttach(NODE, detached) + mgr.attachDetached(NODE, sinks, { cols: 80, rows: 24, ownerProjectId: 'actual-owner' }) + expect(prepared.fresh).toBe(false) + prepared.attach(sinks) + expect(spawns[1].env.PROJECT_TOKEN).toBeUndefined() + expect(paneOwnerProject(NODE)).toBe('actual-owner') + }) + + it('bounds detached settings reads and spawns without overrides if they hang or reject', async () => { + vi.useFakeTimers() + const read = vi.fn(() => new Promise(() => {})) + const mgr = await manager(read) + const waiting = mgr.prepareDetachedAttach(NODE, detached) + // Backend/PATH preparation may add async turns before the settings deadline is armed. + await vi.waitFor(() => expect(read).toHaveBeenCalledOnce()) + await vi.advanceTimersByTimeAsync(2_000) + ;(await waiting).attach(sinks) + expect(spawns).toHaveLength(1) + expect(spawns[0].env.PROJECT_TOKEN).toBeUndefined() + const rejecting = vi.fn(async () => { throw new Error('EIO') }) + mgr.setProjectSpawnOverrides(rejecting) + // A still-running read is coalesced per project; use a separate project for the reject case. + ;(await mgr.prepareDetachedAttach('other', { ...detached, ownerProjectId: 'rejecting-project' })).attach(sinks) + expect(rejecting).toHaveBeenCalledOnce() + expect(spawns).toHaveLength(2) + }) + + it('treats an unprobeable detached backend as warm and never reads project settings', async () => { + vi.useFakeTimers() + const read = vi.fn(async () => ({ env: { PROJECT_TOKEN: 'changed' } })) + const mgr = await manager(read) + vi.spyOn(mgr, 'sessionExists').mockReturnValue(new Promise(() => {})) + const waiting = mgr.prepareDetachedAttach(NODE, detached) + await vi.advanceTimersByTimeAsync(750) + const prepared = await waiting + expect(prepared.fresh).toBe(false) + expect(read).not.toHaveBeenCalled() + prepared.attach(sinks) + expect(paneOwnerProject(NODE)).toBeUndefined() + }) + it("puts the project's env into the session environment", async () => { await manager(async () => ({ env: { PROJECT_TOKEN: 'abc' } })) await create({ persistKey: NODE, ownerProjectId: PROJECT }) diff --git a/src/core/pty-relay-coattach.test.ts b/src/core/pty-relay-coattach.test.ts new file mode 100644 index 0000000000..c44777e224 --- /dev/null +++ b/src/core/pty-relay-coattach.test.ts @@ -0,0 +1,334 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { initPlatform, resetPlatformForTests } from './platform' +import { fakePlatform, type FakePlatform } from './platform-fake' +import { IPC } from '../shared/ipc' +import { DEFAULT_SETTINGS } from '../shared/types' +import { sessionName } from './tmux-naming' + +/** A13: every local painter co-attaches; only privately attested app painters can be retired. + * The native process/tmux boundary is exercised in relay-coattach.realtmux.test.ts. */ + +interface FakePty { + onDataCb?: (d: string) => void + onExitCb?: (e: { exitCode: number }) => void + killed: boolean +} +const spawned: FakePty[] = [] +const spawnArgs: Array<{ file: string; args: string[] }> = [] +const painterTracks = vi.hoisted(() => [] as Array<{ + options: { userData: string; session: string; pid: number; current(): boolean } + close: ReturnType +}>) +vi.mock('./tmux-painter', () => ({ + trackTmuxPainter: (options: typeof painterTracks[number]['options']) => { + const close = vi.fn() + painterTracks.push({ options, close }) + return { close, settled: Promise.resolve() } + } +})) + +// Pin the persistence backend (see src/core/__fixtures__/no-session-host.ts): whether this suite +// exercised tmux or a real session-host shim must not depend on whether anyone ran a build. +vi.mock('./session-host-backend', async () => + (await import('./__fixtures__/no-session-host')).noSessionHost() +) + +vi.mock('node-pty', () => ({ + spawn: (file: string, args: string[]) => { + const p: FakePty = { killed: false } + spawned.push(p) + spawnArgs.push({ file, args }) + return { + onData: (cb: (d: string) => void) => { + p.onDataCb = cb + }, + onExit: (cb: (e: { exitCode: number }) => void) => { + p.onExitCb = cb + }, + write: () => {}, + resize: () => {}, + pause: () => {}, + resume: () => {}, + kill: () => { + p.killed = true + }, + pid: 1 + } + } +})) + +/** Every tmux/ssh side-call goes through child_process; `has-session` answers from the set. */ +const liveTmuxSessions = new Set() + +vi.mock('child_process', () => { + type Cb = (err: Error | null, res?: string | { stdout: string; stderr: string }, stderr?: string) => void + const execFile = (_file: string, args: string[], a?: unknown, b?: unknown): unknown => { + const cb = (typeof a === 'function' ? a : b) as Cb | undefined + const ok = (stdout: string): void => cb?.(null, { stdout, stderr: '' }) + if (args.includes('has-session')) { + const target = args[args.indexOf('-t') + 1] + if (liveTmuxSessions.has(target)) ok('') + else cb?.(Object.assign(new Error('no such session'), { code: 1 })) + } else if (args[0] === '-ilc') { + cb?.(null, '__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__', '') + } else { + ok('') + } + return {} + } + return { execFile, execFileSync: (): string => '' } +}) + +// Hermetic tmux resolution (issue #160 — see pty-single-user.test.ts): the tmux-backed manager is +// what is under test, so it must not silently become the plain-shell fallback on a host without one. +vi.mock('./tmux-hint', async (importOriginal) => ({ + ...(await importOriginal()), + findFixedTmux: () => '/usr/bin/tmux' +})) + +// Hermetic ssh resolution for the SSH-project leg: `findSsh()` walks the real PATH, and a host +// without an ssh client (this sandbox has none) would silently fall through to the LOCAL tmux +// branch — i.e. test a spawn that never happens for a remote node. +vi.mock('./exec-path', async (importOriginal) => { + const real = await importOriginal() + return { + ...real, + findExecutableSync: (bin: string, fallbacks?: string[]) => + bin === 'ssh' ? '/usr/bin/ssh' : real.findExecutableSync(bin, fallbacks) + } +}) + +// A machine with pty devices to spare (see pty-single-user.test.ts for why this is pinned). +vi.mock('./pty-devices', async (importOriginal) => ({ + ...(await importOriginal()), + readPtyDevices: () => ({ ceiling: 511, inUse: 8 }) +})) + +const ALICE = 1 +const REMOTE = { conn: { host: 'h1', user: 'u' }, controlPath: '/tmp/cm', remoteCwd: '/srv/app' } + +/** The attach flags of a LOCAL tmux spawn: everything between `new-session` and the first `-e`/`-c`. */ +function localAttachFlags(args: string[]): string[] { + const at = args.indexOf('new-session') + expect(at).toBeGreaterThan(-1) + const out: string[] = [] + for (const a of args.slice(at + 1)) { + if (a !== '-A' && a !== '-D') break + out.push(a) + } + return out +} + +/** The tokens of the REMOTE tmux command between `new-session` and `-s` (the ssh argv carries the + * whole remote shell line as one string; every token there is posix-quoted). */ +function remoteAttachTokens(args: string[]): string[] { + const line = args.join(' ') + const at = line.indexOf('new-session') + expect(at).toBeGreaterThan(-1) + const end = line.indexOf(' -s ', at) + return line + .slice(at + 'new-session'.length, end) + .split(/\s+/) + .filter(Boolean) + .map((t) => t.replace(/^'|'$/g, '')) +} + +describe("the app's tmux client beside a relay-served (phone) client — audit A13", () => { + let fake: FakePlatform + let userDataDir: string + + beforeEach(() => { + spawned.length = 0 + spawnArgs.length = 0 + painterTracks.length = 0 + liveTmuxSessions.clear() + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-relayco-')) + fake = fakePlatform({ userDataDir }) + initPlatform(fake) + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + resetPlatformForTests() + // Best effort, as in pty-single-user.test.ts: a fire-and-forget scrollback snapshot may still + // be landing, and a cleanup that fails the suite is worse than a leftover temp dir. + try { + fs.rmSync(userDataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 }) + } catch { + /* a temp dir we could not remove is not a test result */ + } + }) + + async function tmuxManager() { + const { PtyManager } = await import('./pty-manager') + const m = new PtyManager() + m.init(() => DEFAULT_SETTINGS) + m.registerIpc() + return m + } + const create = (persistKey = 'node-1', extra: Record = {}) => + fake.handlers[IPC.ptyCreate](ALICE, { cols: 80, rows: 24, persistKey, ...extra }) as Promise<{ + sessionId: string + fresh: boolean + }> + const sinks = () => ({ onData: vi.fn(), onExit: vi.fn() }) + + it('tracks only the local app painter after creation; relay sinks are never takeover candidates', async () => { + const m = await tmuxManager() + m.attachDetached('node-1', sinks()) + expect(painterTracks).toHaveLength(0) + await create() + expect(painterTracks).toHaveLength(1) + expect(painterTracks[0].options).toMatchObject({ userData: userDataDir, session: 'nt-node-1', pid: 1 }) + expect(painterTracks[0].options.current()).toBe(true) + }) + + it('joins one painter across app viewers, then retires its receipt only on final release', async () => { + const m = await tmuxManager() + const first = await create() + await fake.handlers[IPC.ptyCreate](2, { cols: 100, rows: 40, persistKey: 'node-1' }) + expect(painterTracks).toHaveLength(1) + expect(spawnArgs).toHaveLength(1) + m.kill(ALICE, first.sessionId) + expect(painterTracks[0].close).not.toHaveBeenCalled() + expect(painterTracks[0].options.current()).toBe(true) + m.kill(2, first.sessionId) + expect(painterTracks[0].close).toHaveBeenCalledOnce() + expect(painterTracks[0].options.current()).toBe(false) + }) + + it('retires painter tracking on both process exit and normal app shutdown', async () => { + const m = await tmuxManager() + await create() + spawned[0].onExitCb?.({ exitCode: 0 }) + expect(painterTracks[0].close).toHaveBeenCalledOnce() + expect(painterTracks[0].options.current()).toBe(false) + await create('node-2') + await m.killAll() + expect(painterTracks[1].close).toHaveBeenCalledOnce() + expect(painterTracks[1].options.current()).toBe(false) + }) + + it('co-attaches even when no in-process relay client is known', async () => { + await tmuxManager() + await create() + expect(spawnArgs).toHaveLength(1) + expect(localAttachFlags(spawnArgs[0].args)).toEqual(['-A']) + }) + + it('co-attaches WITHOUT -D while a relay client of the same node is attached', async () => { + const m = await tmuxManager() + liveTmuxSessions.add(sessionName('node-1')) + const phone = sinks() + m.attachDetached('node-1', phone) // the phone, attached first (it started the session) + + await create() // the desktop mounts the node the phone just registered + + expect(spawnArgs).toHaveLength(2) + // The relay's own client never detached anyone… + expect(localAttachFlags(spawnArgs[0].args)).toEqual(['-A']) + // …and the app's client now does not detach the phone either. + expect(localAttachFlags(spawnArgs[1].args)).toEqual(['-A']) + expect(spawnArgs[1].args.slice(-2)).toEqual(['-s', sessionName('node-1')]) + // The phone's pty is untouched and told nothing. + expect(spawned[0].killed).toBe(false) + expect(phone.onExit).not.toHaveBeenCalled() + }) + + it('decides at SPAWN time: a phone that attaches while the app create is in flight is spared', async () => { + const m = await tmuxManager() + liveTmuxSessions.add(sessionName('node-1')) + // create() awaits has-session (a subprocess) before it spawns anything… + const pending = create() + expect(spawnArgs).toHaveLength(0) + // …and the relay attach lands in that window. + m.attachDetached('node-1', sinks()) + await pending + + expect(spawnArgs).toHaveLength(2) + expect(localAttachFlags(spawnArgs[1].args)).toEqual(['-A']) + }) + + it('a relay client of another node does not change safe local attachment', async () => { + const m = await tmuxManager() + m.attachDetached('node-2', sinks()) + await create('node-1') + expect(localAttachFlags(spawnArgs[1].args)).toEqual(['-A']) + }) + + it('preserves unknown external viewers after an in-process relay stream closes', async () => { + const m = await tmuxManager() + const relayId = m.attachDetached('node-1', sinks()) + m.kill(null, relayId) // the phone detached right before the desktop mounted the node + await create() + expect(localAttachFlags(spawnArgs[1].args)).toEqual(['-A']) + }) + + it("preserves unknown external viewers after a relay pty exits", async () => { + const m = await tmuxManager() + m.attachDetached('node-1', sinks()) + spawned[0].onExitCb?.({ exitCode: 0 }) + await create() + expect(localAttachFlags(spawnArgs[1].args)).toEqual(['-A']) + }) + + it('the next remount (park expiry, offscreen revive) still spares a phone that stayed', async () => { + const m = await tmuxManager() + liveTmuxSessions.add(sessionName('node-1')) + m.attachDetached('node-1', sinks()) + const first = await create() + m.kill(ALICE, first.sessionId) // the desktop node unmounts; the phone keeps watching + await create() + + expect(spawnArgs).toHaveLength(3) + expect(localAttachFlags(spawnArgs[2].args)).toEqual(['-A']) + expect(spawned[0].killed).toBe(false) + }) + + it('an SSH-project node never passes -D on the remote host, with or without a relay client', async () => { + const m = await tmuxManager() + const relayOpts = { cols: 80, rows: 24, sshRemote: REMOTE, requireRemote: true } + // The renderer's remote attach with no phone attached, then the phone joining it… + await create('node-r', { sshRemote: REMOTE, requireRemote: true }) + m.attachDetached('node-r', sinks(), relayOpts) + // …and the reverse order: the phone attached over the same ControlMaster first (audit A09's + // relay path), then the desktop mounts the node. + m.attachDetached('node-r2', sinks(), relayOpts) + await create('node-r2', { sshRemote: REMOTE, requireRemote: true }) + + expect(m.sshRemoteForNode('node-r')).toBeDefined() // the remote branch really ran + const remoteSpawns = spawnArgs.filter((s) => s.file === '/usr/bin/ssh') + expect(remoteSpawns).toHaveLength(4) + for (const s of remoteSpawns) { + const tokens = remoteAttachTokens(s.args) + expect(tokens[0]).toBe('-A') + expect(tokens).not.toContain('-D') + } + }) + + it('a relay client over an SSH master leaves local external viewers untouched', async () => { + // Not a state the app reaches (a remote node refuses a local spawn — `requireRemote`), but the + // predicate must ask about the tmux server the spawn attaches to, not about the node id alone. + const m = await tmuxManager() + m.attachDetached('node-1', sinks(), { cols: 80, rows: 24, sshRemote: REMOTE, requireRemote: true }) + await create('node-1') + const local = spawnArgs.filter((s) => s.file !== '/usr/bin/ssh') + expect(local).toHaveLength(1) + expect(localAttachFlags(local[0].args)).toEqual(['-A']) + }) +}) + +describe('tmuxAttachFlags (A13)', () => { + it('drops -D for a relay-served pty and for the app client beside one, and only then', async () => { + const { tmuxAttachFlags } = await import('./pty-manager') + expect(tmuxAttachFlags(false)).toEqual(['-A']) + expect(tmuxAttachFlags(false, false)).toEqual(['-A']) + expect(tmuxAttachFlags(false, true)).toEqual(['-A']) + expect(tmuxAttachFlags(true)).toEqual(['-A']) + expect(tmuxAttachFlags(true, true)).toEqual(['-A']) + }) +}) diff --git a/src/core/pty-relay-create.test.ts b/src/core/pty-relay-create.test.ts new file mode 100644 index 0000000000..e6fd76ab18 --- /dev/null +++ b/src/core/pty-relay-create.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { initPlatform, resetPlatformForTests } from './platform' +import { fakePlatform, type FakePlatform } from './platform-fake' +import { IPC } from '../shared/ipc' +import { DEFAULT_SETTINGS } from '../shared/types' +import { sessionName } from './tmux-naming' +import { hookServer, PERM_WAIT_SECS_DEFAULT } from './agents/hook-server' +import { paneOwnerProject, resetPaneOwnershipForTests } from './agents/pane-ownership' +import { codexLauncherDir } from './codex-identity-proxy' + +/** + * AUDIT A72 — a session the PHONE starts gets the agent's env and a proven owner. + * + * The relay `pty.attach` of a fresh node id creates its tmux session through `attachDetached`, and + * tmux reads `-e` only at creation: whatever env that spawn lacks, the session lacks for life. A33 + * made the relay host resolve the phone's project/account/agent and pass them here when the attach + * creates the session; this file pins what that spawn then carries, and the one thing it lacked: + * + * - the agent-gated hook env (`NODETERM_AGENT_ID`, the Claude hook-reply wait, the canvas-control + * grant `canControlCanvas` decides) and, for Codex, the managed launcher's directory on PATH — + * the same `-e` pairs a canvas-started spawn gets, asserted on the tmux ARGV because that is + * what the session is created from; + * - PANE OWNERSHIP: recorded for the host-resolved owner (`agents/pane-ownership.ts`), and never + * for an attach this process can see is a join — so a phone cannot claim a live pane. + * + * Driven through a REAL hook server (as hook-server.env.test.ts does): a stubbed `buildPtyEnv` + * would assert the arguments, not the env the session ends up with. + */ + +const spawnArgs: string[][] = [] + +vi.mock('./session-host-backend', async () => + (await import('./__fixtures__/no-session-host')).noSessionHost() +) + +vi.mock('node-pty', () => ({ + spawn: (_file: string, args: string[]) => { + spawnArgs.push(args) + return { + onData: () => {}, + onExit: () => {}, + write: () => {}, + resize: () => {}, + pause: () => {}, + resume: () => {}, + kill: () => {}, + pid: 1 + } + } +})) + +/** Every tmux side-call goes through child_process; `has-session` answers from the set. */ +const liveTmuxSessions = new Set() + +vi.mock('child_process', () => { + type Cb = (err: Error | null, res?: string | { stdout: string; stderr: string }, stderr?: string) => void + const execFile = (_file: string, args: string[], a?: unknown, b?: unknown): unknown => { + const cb = (typeof a === 'function' ? a : b) as Cb | undefined + const ok = (stdout: string): void => cb?.(null, { stdout, stderr: '' }) + if (args.includes('has-session')) { + const target = args[args.indexOf('-t') + 1] + if (liveTmuxSessions.has(target)) ok('') + else cb?.(Object.assign(new Error('no such session'), { code: 1 })) + } else if (args[0] === '-ilc') { + cb?.(null, '__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__', '') + } else { + ok('') + } + return {} + } + return { execFile, execFileSync: (): string => '' } +}) + +// Hermetic tmux resolution (issue #160): the tmux-backed spawn is what is under test. +vi.mock('./tmux-hint', async (importOriginal) => ({ + ...(await importOriginal()), + findFixedTmux: () => '/usr/bin/tmux' +})) + +// A machine with pty devices to spare (see pty-single-user.test.ts). +vi.mock('./pty-devices', async (importOriginal) => ({ + ...(await importOriginal()), + readPtyDevices: () => ({ ceiling: 511, inUse: 8 }) +})) + +const ALICE = 1 +const NODE = 'term-new-1' + +/** The session env tmux is told to create the session with: every `-e KEY=VALUE` on the argv. */ +function sessionEnv(args: string[]): Record { + const env: Record = {} + args.forEach((a, i) => { + if (a !== '-e') return + const pair = args[i + 1] + const eq = pair.indexOf('=') + env[pair.slice(0, eq)] = pair.slice(eq + 1) + }) + return env +} + +describe('a session the phone starts over the relay — audit A72', () => { + let fake: FakePlatform + let userDataDir: string + + beforeAll(async () => { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-relaycreate-')) + fake = fakePlatform({ userDataDir }) + initPlatform(fake) + // buildPtyEnv answers {} until the server has a port and a token. + await hookServer.start() + }) + afterAll(() => { + hookServer.stop() + resetPlatformForTests() + try { + fs.rmSync(userDataDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 }) + } catch { + /* a temp dir we could not remove is not a test result */ + } + }) + beforeEach(() => { + spawnArgs.length = 0 + liveTmuxSessions.clear() + resetPaneOwnershipForTests() + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + resetPaneOwnershipForTests() + }) + + async function tmuxManager() { + const { PtyManager } = await import('./pty-manager') + const m = new PtyManager() + m.init(() => ({ ...DEFAULT_SETTINGS, hookReplyApprovals: true })) + m.registerIpc() + return m + } + const sinks = () => ({ onData: vi.fn(), onExit: vi.fn() }) + const create = (extra: Record = {}) => + fake.handlers[IPC.ptyCreate](ALICE, { cols: 80, rows: 24, persistKey: NODE, ...extra }) as Promise<{ + sessionId: string + fresh: boolean + }> + + it("creates a Claude session with the agent's hook env: identity, approval wait, canvas control", async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, cwd: userDataDir, agentId: 'claude', ownerProjectId: 'proj-A' }) + + expect(spawnArgs).toHaveLength(1) + const env = sessionEnv(spawnArgs[0]) + expect(env.NODETERM_NODE_ID).toBe(NODE) + expect(env.NODETERM_AGENT_ID).toBe('claude') + // Deterministic hook-reply approvals: without this the phone's `approvals.answer` has nothing + // held to answer, and the permission prompt only ever reaches the pane. + expect(env.NODETERM_PERM_WAIT_SECS).toBe(String(PERM_WAIT_SECS_DEFAULT)) + expect(env.NODETERM_CANVAS_CONTROL).toBe('1') + expect(spawnArgs[0][spawnArgs[0].indexOf('-s') + 1]).toBe(sessionName(NODE)) + }) + + it("puts a Codex session's managed launcher directory first on its PATH", async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, agentId: 'codex', ownerProjectId: 'proj-A' }) + const env = sessionEnv(spawnArgs[0]) + expect(env.NODETERM_AGENT_ID).toBe('codex') + expect(env.PATH.split(path.delimiter)[0]).toBe(codexLauncherDir()) + }) + + it('an attach with no agent (a join, an older phone) keeps the bare hook env it always had', async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24 }) + const env = sessionEnv(spawnArgs[0]) + // Status badges and inbox events never depended on the agent: node id + endpoint are there. + expect(env.NODETERM_NODE_ID).toBe(NODE) + expect(env.NODETERM_HOOK_ENDPOINT).toBeTruthy() + expect(env).not.toHaveProperty('NODETERM_AGENT_ID') + expect(env).not.toHaveProperty('NODETERM_PERM_WAIT_SECS') + expect(env).not.toHaveProperty('NODETERM_CANVAS_CONTROL') + }) + + it("records the pane's host-resolved owner when the relay host creates the session", async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, agentId: 'claude', ownerProjectId: 'proj-A' }) + expect(paneOwnerProject(NODE)).toBe('proj-A') + }) + + it('records nothing for an attach that names no owner (every join, every older phone)', async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, agentId: 'claude' }) + expect(paneOwnerProject(NODE)).toBeUndefined() + }) + + it('a live generation in this process makes it a JOIN: the phone cannot claim the pane', async () => { + const m = await tmuxManager() + // The canvas spawned the node first, for its own project… + await create({ ownerProjectId: 'proj-A' }) + expect(paneOwnerProject(NODE)).toBe('proj-A') + // …then an attach naming another project arrives (a caller whose probe was stale). + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, ownerProjectId: 'proj-B' }) + expect(paneOwnerProject(NODE)).toBe('proj-A') + }) + + it('a join of a session another relay stream holds records nothing either', async () => { + const m = await tmuxManager() + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24 }) + m.attachDetached(NODE, sinks(), { cols: 80, rows: 24, ownerProjectId: 'proj-B' }) + expect(paneOwnerProject(NODE)).toBeUndefined() + }) +}) diff --git a/src/core/pty-shadow.test.ts b/src/core/pty-shadow.test.ts index d03910a35a..1f7befb538 100644 --- a/src/core/pty-shadow.test.ts +++ b/src/core/pty-shadow.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +vi.mock('./native-windows-pane', () => ({ NativeWindowsPane: class {} })) import fs from 'fs' import os from 'os' import path from 'path' @@ -76,7 +77,7 @@ vi.mock('node-pty', () => ({ const liveTmuxSessions = new Set() vi.mock('child_process', () => { - type Cb = (err: Error | null, res?: { stdout: string; stderr: string }) => void + type Cb = (err: Error | null, res?: string | { stdout: string; stderr: string }, stderr?: string) => void const execFile = (file: string, args: string[], a?: unknown, b?: unknown): unknown => { const cb = (typeof a === 'function' ? a : b) as Cb | undefined const ok = (stdout: string): void => cb?.(null, { stdout, stderr: '' }) @@ -85,7 +86,7 @@ vi.mock('child_process', () => { if (liveTmuxSessions.has(target)) ok('') else cb?.(Object.assign(new Error('no such session'), { code: 1 })) } else if (args[0] === '-ilc') { - ok('__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__') + cb?.(null, '__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__', '') } else { ok('') } @@ -128,6 +129,7 @@ class FakeControlSpawn implements ControlSpawn { feed: (s) => onData?.(Buffer.from(s, 'latin1')) } this.children.push(child) + void Promise.resolve().then(() => child.feed('%begin 1700 0 0\n%end 1700 0 0\n')) return { stdin: { write: (s: string) => { diff --git a/src/core/pty-single-user.test.ts b/src/core/pty-single-user.test.ts index 189ffb8958..05de05e945 100644 --- a/src/core/pty-single-user.test.ts +++ b/src/core/pty-single-user.test.ts @@ -96,7 +96,7 @@ let paneProcessReply = '' let processGroupReply = '' vi.mock('child_process', () => { - type Cb = (err: Error | null, res?: { stdout: string; stderr: string }) => void + type Cb = (err: Error | null, res?: string | { stdout: string; stderr: string }, stderr?: string) => void const execFile = (file: string, args: string[], a?: unknown, b?: unknown): unknown => { const cb = (typeof a === 'function' ? a : b) as Cb | undefined execCalls.push({ file, args }) @@ -108,8 +108,8 @@ vi.mock('child_process', () => { // reads as genuine absence (vs a spawn failure, which has a string/no code). else cb?.(Object.assign(new Error('no such session'), { code: 1 })) } else if (args[0] === '-ilc') { - // The login-shell PATH probe (`resolveShellPath`). - ok('__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__') + // The login-shell probe uses Node's direct callback stdout argument. + cb?.(null, '__NT_PATH_START__/usr/bin:/bin__NT_PATH_END__', '') } else if (args.includes('capture-pane')) { ok('PANE SNAPSHOT') } else if (args.includes('#{pane_pid}|#{pane_current_command}')) { @@ -242,7 +242,7 @@ describe('SINGLE-USER REGRESSION: co-attach must not change the solo path', () = }) // ── The spawn itself: same tmux args, same cwd, same env ────────────────────────────────── - it('spawns ONE tmux client with the unchanged attach flags, cwd and session name', async () => { + it('spawns one tmux client without detaching other viewers, preserving cwd and session name', async () => { const m = await tmuxManager() const cwd = testTmpDir('nt-cwd-') await create(80, 24, 'solo-1', { cwd }) @@ -250,17 +250,17 @@ describe('SINGLE-USER REGRESSION: co-attach must not change the solo path', () = expect(spawned).toHaveLength(1) const { file, args, env } = spawnArgs[0] expect(file).toBe(m.getTmuxBin()) - // The dedicated socket + generated conf, then attach-or-create with -D: the app has exactly - // ONE tmux client per session, so tmux's own multi-client size negotiation never engages. - expect(args.slice(0, 7)).toEqual([ + // The dedicated socket + generated conf, then attach-or-create with -A: one app client + // joins the session without detaching external phone or relay viewers. + expect(args.slice(0, 6)).toEqual([ '-L', TMUX_SOCKET, '-f', path.join(userDataDir, 'tmux.conf'), 'new-session', - '-A', - '-D' + '-A' ]) + expect(args).not.toContain('-D') expect(args.filter((a) => a === 'new-session')).toHaveLength(1) expect(args[args.indexOf('-c') + 1]).toBe(cwd) expect(args.slice(-2)).toEqual(['-s', sessionName('solo-1')]) diff --git a/src/core/pty-watch-join.test.ts b/src/core/pty-watch-join.test.ts index ac78107166..15f71694ad 100644 --- a/src/core/pty-watch-join.test.ts +++ b/src/core/pty-watch-join.test.ts @@ -380,7 +380,9 @@ describe('joinAsWatcher', () => { expect(a.sessionId).not.toBe(w.sessionId) expect(spawned).toHaveLength(2) expect(spawned[1].args).toContain('new-session') - expect(spawned[1].args).toContain('-D') + expect(spawned[1].args).toContain('-A') + expect(spawned[1].args).not.toContain('-D') + expect(spawned[1].args).not.toContain('-d') }) it("a watcher's client is not a painter: it does not retire the node's background-write clients", async () => { diff --git a/src/core/relay-coattach.realtmux.test.ts b/src/core/relay-coattach.realtmux.test.ts new file mode 100644 index 0000000000..454b0c5e87 --- /dev/null +++ b/src/core/relay-coattach.realtmux.test.ts @@ -0,0 +1,275 @@ +// A13 against real tmux. Ordinary attach-only control clients represent the external SSH phone +// at the actual tmux-client boundary; app painters use the production attach flags and tracker. +// No native node-pty/SSH/device proof is claimed here. The legacy -D control proves the harness +// observes detachment, while current claims run the actual server-side identity format gate. +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import { execFileSync, spawn, type ChildProcess } from 'child_process' +import fs from 'fs' +import path from 'path' +import { tmuxAttachFlags, tmuxConf } from './pty-manager' +import { trackTmuxPainter, painterDetachArgs } from './tmux-painter' +import { makeTmuxTmpdir } from './tmux-test-socket' + +// Only two PURE exports of pty-manager are used here; the module still imports node-pty at load, +// and a checkout whose native build was skipped (`npm ci --ignore-scripts`) has no binary for it. +// Nothing below spawns through it — the clients are plain `child_process` children. +vi.mock('node-pty', () => ({ + spawn: () => { + throw new Error('node-pty is not used by this suite') + } +})) + +/** A private socket, never the app's — this must not touch a running nodeterm's tmux server. */ +const SOCKET = `nt-relayco-${process.pid}` + +function findTmux(): string | null { + for (const c of ['/usr/bin/tmux', '/usr/local/bin/tmux', '/opt/homebrew/bin/tmux', '/bin/tmux']) { + if (fs.existsSync(c)) return c + } + return null +} + +const TMUX = process.platform === 'win32' ? null : findTmux() +let work: string +let conf: string +const children: ChildProcess[] = [] +const trackers: Array> = [] + +/** The sandbox dir LAST: a caller never chooses which server it reaches. */ +function tmuxEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, TMUX_TMPDIR: work } + delete env.TMUX + delete env.TMUX_PANE + return env +} + +function tmux(args: string[]): string { + return execFileSync(TMUX as string, ['-L', SOCKET, ...args], { + encoding: 'utf8', + env: tmuxEnv(), + stdio: ['ignore', 'pipe', 'pipe'] + }) +} + +/** A tmux client attaching the way `spawnSession` attaches a node: `new-session -s `. */ +function attach(session: string, flags: string[]): ChildProcess { + const child = spawn( + TMUX as string, + ['-L', SOCKET, '-f', conf, '-C', 'new-session', ...flags, '-s', session, 'sleep', '300'], + // stdin stays OPEN: a control client exits on EOF, which would read as a detach. + { env: tmuxEnv(), stdio: ['pipe', 'pipe', 'pipe'] } + ) + child.stdout?.resume() + child.stderr?.resume() + children.push(child) + return child +} + +/** The pids of the clients attached to `session` — the tmux client processes themselves. */ +function clientPids(session: string): number[] { + try { + return tmux(['list-clients', '-t', `=${session}`, '-F', '#{client_pid}']) + .split('\n') + .filter(Boolean) + .map(Number) + } catch { + return [] + } +} + +function phoneAttach(session: string): ChildProcess { + const child = spawn(TMUX as string, ['-L', SOCKET, '-C', 'attach-session', '-t', `=${session}`], + { env: tmuxEnv(), stdio: ['pipe', 'pipe', 'pipe'] }) + child.stdout?.resume(); child.stderr?.resume(); children.push(child) + return child +} + +function own(child: ChildProcess, session: string, userData = work) { + const handle = trackTmuxPainter({ userData, tmux: TMUX as string, socket: SOCKET, session, + pid: child.pid as number, current: () => child.exitCode === null && child.signalCode === null, + run: async (args) => tmux(args.slice(2)) }) + trackers.push(handle) + return handle +} + +function identity(session: string, child: ChildProcess) { + const lines = tmux(['list-clients', '-t', `=${session}`, '-F', '#{client_pid}|#{client_created}|#{client_name}|#{pid}']).trim().split('\n') + const line = lines.find((row) => row.startsWith(`${child.pid}|`)) + if (!line) throw new Error('Owned test client is missing') + const [pid, created, name, server] = line.split('|') + return { pid: Number(pid), created, name, server: Number(server), session } +} + +function setGrid(session: string, child: ChildProcess, width: number, height: number): void { + const c = identity(session, child) + tmux(['refresh-client', '-t', c.name, '-C', `${width}x${height}`]) +} + +function widths(session: string): string[] { + return tmux(['list-clients', '-t', `=${session}`, '-F', '#{client_pid}:#{client_width}']).trim().split('\n').sort() +} + +function paneGrid(session: string): string { + // tmux 3.7c leaves client_height empty for control clients, even in its default listing. + // Actual pane geometry still measures both axes; do not reduce the unequal-grid proof to widths. + return tmux(['display-message', '-p', '-t', `=${session}:`, '#{pane_width}x#{pane_height}']).trim() +} + +async function waitUntil(pred: () => boolean, ms = 4000): Promise { + const t0 = Date.now() + while (!pred()) { + if (Date.now() - t0 > ms) throw new Error('timed out') + await new Promise((r) => setTimeout(r, 25)) + } +} + +/** Resolve with the exit code, or `'still-attached'` if the client is alive after `ms`. */ +function exitWithin(child: ChildProcess, ms: number): Promise { + if (child.exitCode !== null) return Promise.resolve(child.exitCode) + return new Promise((resolve) => { + const timer = setTimeout(() => resolve('still-attached'), ms) + child.once('exit', (code) => { + clearTimeout(timer) + resolve(code) + }) + }) +} + +beforeAll(() => { + if (!TMUX) return + work = makeTmuxTmpdir('ntrelayco-', SOCKET) + conf = path.join(work, 'tmux.conf') + fs.writeFileSync(conf, tmuxConf(2000)) +}) + +afterAll(() => { + if (!TMUX) return + for (const tracker of trackers) tracker.close() + for (const c of children) if (c.exitCode === null) c.kill() + try { + tmux(['kill-server']) // our PRIVATE socket — nothing else can be on it + } catch { + /* already gone */ + } + fs.rmSync(work, { recursive: true, force: true }) +}) + +describe('a relay-served client beside the app client on a real tmux (audit A13)', () => { + it.skipIf(!TMUX)('control: the app attaching with -A -D detaches the phone, which exits 0', async () => { + const session = 'nt-a13-kick' + const phone = attach(session, tmuxAttachFlags(true)) // the relay-served pty (attachDetached) + await waitUntil(() => clientPids(session).includes(phone.pid as number)) + + // The pre-A13 app client: no relay client known, or not asked. + const app = attach(session, ['-A', '-D']) + await waitUntil(() => clientPids(session).includes(app.pid as number)) + + expect(await exitWithin(phone, 3000)).toBe(0) + expect(clientPids(session)).toEqual([app.pid]) + }) + + it.skipIf(!TMUX)('the fix: the app attaching beside a live relay client leaves the phone attached', async () => { + const session = 'nt-a13-beside' + const phone = attach(session, tmuxAttachFlags(true)) + await waitUntil(() => clientPids(session).includes(phone.pid as number)) + + // What `spawnSession` now builds while a relay-served client of the node is attached. + const app = attach(session, tmuxAttachFlags(false, true)) + await waitUntil(() => clientPids(session).includes(app.pid as number)) + + // A `-D` detach happens while tmux processes the attach, before the new client is listed, so + // the control above exits well inside this window. + expect(await exitWithin(phone, 750)).toBe('still-attached') + expect(clientPids(session).sort()).toEqual([phone.pid, app.pid].sort()) + }) + + it.skipIf(!TMUX)('direct SSH first: remount retires only an owned app painter and keeps unequal phone grids', async () => { + const session = 'nt-a13-ssh-first' + tmux(['new-session', '-d', '-s', session, 'sleep', '300']) + const phone = phoneAttach(session) + await waitUntil(() => clientPids(session).includes(phone.pid as number)) + setGrid(session, phone, 56, 48) + await waitUntil(() => paneGrid(session) === '56x48') + const first = attach(session, tmuxAttachFlags(false)) + await own(first, session).settled + setGrid(session, first, 120, 30) + await waitUntil(() => paneGrid(session) === '120x30') + expect(widths(session)).toEqual([`${phone.pid}:56`, `${first.pid}:120`].sort()) + + const next = attach(session, tmuxAttachFlags(false)) + const replacement = own(next, session) + await replacement.settled + await waitUntil(() => first.exitCode !== null) + setGrid(session, next, 100, 40) + await waitUntil(() => paneGrid(session) === '100x40') + expect(first.exitCode).toBe(0) + expect(await exitWithin(phone, 150)).toBe('still-attached') + expect(clientPids(session).sort()).toEqual([phone.pid, next.pid].sort()) + expect(widths(session)).toEqual([`${phone.pid}:56`, `${next.pid}:100`].sort()) + replacement.close(); next.kill() + await waitUntil(() => clientPids(session).length === 1 && paneGrid(session) === '56x48') + expect(clientPids(session)).toEqual([phone.pid]) + expect(await exitWithin(phone, 150)).toBe('still-attached') + }) + + it.skipIf(!TMUX)('app first: a direct SSH phone stays through later app takeover and release', async () => { + const session = 'nt-a13-app-first' + const first = attach(session, tmuxAttachFlags(false)) + const initial = own(first, session) + await initial.settled + const phone = phoneAttach(session) + await waitUntil(() => clientPids(session).includes(phone.pid as number)) + const next = attach(session, tmuxAttachFlags(false)) + const replacement = own(next, session) + await replacement.settled + await waitUntil(() => first.exitCode !== null) + expect(clientPids(session).sort()).toEqual([phone.pid, next.pid].sort()) + replacement.close(); next.kill() + await waitUntil(() => next.exitCode !== null || next.signalCode !== null) + expect(clientPids(session)).toEqual([phone.pid]) + expect(await exitWithin(phone, 150)).toBe('still-attached') + }) + + it.skipIf(!TMUX)('does not claim another profile or an unmarked legacy app client', async () => { + const session = 'nt-a13-other-profile' + const old = attach(session, tmuxAttachFlags(false)) + const otherProfile = fs.mkdtempSync(path.join(work, 'other-profile-')) + await own(old, session, otherProfile).settled + const unmarked = attach(session, tmuxAttachFlags(false)) + await waitUntil(() => clientPids(session).includes(unmarked.pid as number)) + const next = attach(session, tmuxAttachFlags(false)) + await own(next, session).settled + expect(clientPids(session).sort()).toEqual([old.pid, unmarked.pid, next.pid].sort()) + expect(await exitWithin(old, 150)).toBe('still-attached') + expect(await exitWithin(unmarked, 150)).toBe('still-attached') + }) + + it.skipIf(!TMUX)('the server-side gate preserves the old client when the incoming painter disappears', async () => { + const session = 'nt-a13-new-gone' + const old = attach(session, ['-A']) + const incoming = attach(session, ['-A']) + await waitUntil(() => clientPids(session).length === 2) + const args = painterDetachArgs(SOCKET, identity(session, old), identity(session, incoming)) + incoming.kill() + await waitUntil(() => !clientPids(session).includes(incoming.pid as number)) + tmux(args.slice(2)) + expect(await exitWithin(old, 150)).toBe('still-attached') + expect(clientPids(session)).toEqual([old.pid]) + }) + + it.skipIf(!TMUX)('the server-side gate refuses an old client that switched to another session', async () => { + const session = 'nt-a13-old-switched' + const old = attach(session, ['-A']) + const incoming = attach(session, ['-A']) + await waitUntil(() => clientPids(session).length === 2) + const oldIdentity = identity(session, old) + const args = painterDetachArgs(SOCKET, oldIdentity, identity(session, incoming)) + const other = 'nt-a13-different' + tmux(['new-session', '-d', '-s', other, 'sleep', '300']) + tmux(['switch-client', '-c', oldIdentity.name, '-t', `=${other}`]) + tmux(args.slice(2)) + expect(await exitWithin(old, 150)).toBe('still-attached') + expect(clientPids(other)).toEqual([old.pid]) + expect(clientPids(session)).toEqual([incoming.pid]) + }) +}) diff --git a/src/core/remote-send-keys.realtmux.test.ts b/src/core/remote-send-keys.realtmux.test.ts new file mode 100644 index 0000000000..891e91f34e --- /dev/null +++ b/src/core/remote-send-keys.realtmux.test.ts @@ -0,0 +1,303 @@ +// A RELAY PHONE'S QUICK ANSWER INTO A NODE OF AN SSH PROJECT, PROVEN AGAINST A REAL TMUX. +// +// The subject is `remoteTmuxSendKeysArgs` — the line `PtyManager.backgroundWriteOver` runs over a +// project's ControlMaster when the phone answers a question (a digit) or a legacy approval (`1`, or +// a lone ESC for Deny) on a node whose tmux lives on another host (`node.sendKeys`, the follow-up to +// audit A12). It is generated shell that no compiler checks, so the line is run for real: its last +// argv element (the remote command) goes through a real /bin/sh, with a `tmux` shim on PATH that +// swaps the production socket for this file's private one, into a real tmux whose pane runs a +// program recording the raw bytes it receives. +// +// WHAT JUDGES. The bytes on the application's stdin — not tmux's exit status, which is exactly the +// thing that lies in two of the cases below (a key eaten by copy mode, a key typed into another +// session by prefix matching: both exit 0). +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import { execFileSync } from 'child_process' +import fs from 'fs' +import path from 'path' +import { remoteTmuxSendKeysArgs } from './remote-ssh/control-master' +import type { PaneOwner } from '../shared/agents/pane-owner-predicate' +import { makeTmuxTmpdir } from './tmux-test-socket' +import { sendBackgroundTmuxKeys } from './pty-manager' + +// The local delivery is exported from the manager so it can be judged on a real interpreter, +// without loading node-pty's Electron native binding or spawning an attached painter. +vi.mock('node-pty', () => ({ spawn: () => { throw new Error('quick answers must not spawn a PTY') } })) +vi.mock('./native-windows-pane', () => ({ NativeWindowsPane: class {} })) + +/** A private socket — never `TMUX_SOCKET`/`nodeterm-rmt`, which carry live user sessions. */ +const SOCKET = `nt-rsk-test-${process.pid}` +const CONN = { host: 'h.example.com', user: 'deploy', port: 2222 } + +function findTmux(): string | null { + for (const c of ['/usr/bin/tmux', '/usr/local/bin/tmux', '/opt/homebrew/bin/tmux', '/bin/tmux']) { + if (fs.existsSync(c)) return c + } + return null +} +const TMUX = findTmux() + +let work: string +let binDir: string + +function env(): NodeJS.ProcessEnv { + return { ...process.env, TMUX_TMPDIR: work } +} + +function tmux(args: string[]): string { + // stderr piped, not inherited: the `has-session` probes below fail by design. + return execFileSync(TMUX as string, args, { encoding: 'utf8', env: env(), stdio: 'pipe' }) +} + +beforeAll(() => { + if (!TMUX) return + work = makeTmuxTmpdir('ntrsk-', SOCKET) + binDir = path.join(work, 'bin') + fs.mkdirSync(binDir) + // The SSH path's shim: the remote line hard-codes `-L nodeterm-rmt` (on a real host, the socket a + // remote nodeterm owns); the first two arguments are dropped and the real tmux is re-invoked on + // this file's private socket. + fs.writeFileSync( + path.join(binDir, 'tmux'), + `#!/bin/sh\nexport TMUX_TMPDIR=${work}\nshift 2\nexec ${TMUX} -L ${SOCKET} "$@"\n`, + { mode: 0o755 } + ) + // A pane program that records the RAW bytes its stdin receives (`stty raw`: no CR→NL rewrite, and + // a lone ESC passes as one byte). + fs.writeFileSync( + path.join(binDir, 'recorder'), + ['#!/bin/sh', 'stty raw -echo', 'touch "$1.ready"', 'exec cat > "$1"'].join('\n') + '\n', + { mode: 0o755 } + ) +}) + +afterAll(() => { + if (TMUX) { + try { + tmux(['-L', SOCKET, 'kill-server']) + } catch { + // already gone + } + } + if (work) fs.rmSync(work, { recursive: true, force: true }) +}) + +function waitFor(predicate: () => boolean, what: string, ms = 10_000): void { + const deadline = Date.now() + ms + while (!predicate()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`) + execFileSync('sleep', ['0.03']) + } +} + +function sessionExists(session: string): boolean { + try { + tmux(['-L', SOCKET, 'has-session', '-t', `=${session}`]) + return true + } catch { + return false + } +} + +/** A fresh single-pane session running the byte recorder. Returns the file it writes to. */ +function recorderPane(session: string): string { + const out = path.join(work, `${session}.bytes`) + if (sessionExists(session)) tmux(['-L', SOCKET, 'kill-session', '-t', `=${session}`]) + tmux(['-L', SOCKET, 'new-session', '-d', '-s', session, '-x', '120', '-y', '30', '-c', work, + `${binDir}/recorder ${out}`]) + waitFor(() => fs.existsSync(`${out}.ready`), `${session} recorder to come up`) + return out +} + +/** + * Everything sent before this call has reached the application. A sentinel delivered by a + * DIFFERENT mechanism (a plain literal send-keys, exact target), so a subject that delivered nothing + * still yields an answer — an empty one — instead of a hang. + */ +const MARK = '<>' +function drain(session: string, out: string, pane = `=${session}:`): string { + tmux(['-L', SOCKET, 'send-keys', '-t', pane, '-l', '--', MARK]) + waitFor(() => fs.readFileSync(out, 'utf8').includes(MARK), `${session} to receive the marker`) + const all = fs.readFileSync(out, 'utf8') + return all.slice(0, all.lastIndexOf(MARK)) +} + +/** Run a remote line the way ssh would hand it to the host's shell. Throws on a non-zero exit. */ +function runRemote(line: string): void { + execFileSync('/bin/sh', ['-c', line], { + env: { PATH: `${binDir}:/usr/bin:/bin`, HOME: work }, + stdio: 'pipe' + }) +} + +/** Exactly what `PtyManager.backgroundWriteOver` runs: the builder's line, true iff it exited 0. */ +function sendOver(session: string, keys: string): boolean { + const args = remoteTmuxSendKeysArgs(CONN, '/cm.sock', session, keys) + try { + runRemote(args[args.length - 1]) + return true + } catch { + return false + } +} + +const suite = TMUX && process.platform !== 'win32' ? describe : describe.skip + +suite('REAL tmux: a quick answer typed over the master reaches the remote pane', () => { + it('delivers a question digit as that one byte', () => { + const out = recorderPane('nt-rsk-digit') + expect(sendOver('nt-rsk-digit', '2')).toBe(true) + expect(drain('nt-rsk-digit', out)).toBe('2') + }) + + it('delivers a lone ESC (Deny) and an Enter as the raw bytes a key press is', () => { + const out = recorderPane('nt-rsk-keys') + expect(sendOver('nt-rsk-keys', '\u001b')).toBe(true) + expect(sendOver('nt-rsk-keys', '\r')).toBe(true) + expect(sendOver('nt-rsk-keys', '1\r')).toBe(true) + expect(drain('nt-rsk-keys', out)).toBe('\u001b\r1\r') + }) + + it('an answer full of shell and tmux syntax arrives as text — nothing is parsed out of it', () => { + const out = recorderPane('nt-rsk-text') + const hostile = `-R ';$(id)\\` + expect(sendOver('nt-rsk-text', hostile)).toBe(true) + expect(drain('nt-rsk-text', out)).toBe(hostile) + }) + + it.each(['emacs', 'vi'])( + 'a pane in copy mode (%s keys) is brought back first — the key reaches the app, not the copy-mode table', + (keys) => { + const session = `nt-rsk-copy-${keys}` + const out = recorderPane(session) + tmux(['-L', SOCKET, 'set-option', '-w', '-t', `=${session}:`, 'mode-keys', keys]) + tmux(['-L', SOCKET, 'copy-mode', '-t', `=${session}:`]) + const inMode = (): string => + tmux(['-L', SOCKET, 'display-message', '-p', '-t', `=${session}:`, '#{pane_in_mode}']).trim() + expect(inMode()).toBe('1') + expect(sendOver(session, '3')).toBe(true) + expect(drain(session, out)).toBe('3') + expect(inMode()).toBe('0') + } + ) + + it('CONTROL: without the cancel, copy mode EATS the key — and with emacs keys tmux still exits 0', () => { + // Why the cancel is in the line at all: a delivery reported that never happened. (With vi keys + // the digit opens a repeat-count prompt instead, which fails with no client attached and opens + // on the desktop's client when there is one; the app never sees the key either way.) + const out = recorderPane('nt-rsk-eaten') + tmux(['-L', SOCKET, 'set-option', '-w', '-t', '=nt-rsk-eaten:', 'mode-keys', 'emacs']) + tmux(['-L', SOCKET, 'copy-mode', '-t', '=nt-rsk-eaten:']) + tmux(['-L', SOCKET, 'send-keys', '-t', '=nt-rsk-eaten:', '-H', '33']) // exits 0: it would throw otherwise + tmux(['-L', SOCKET, 'send-keys', '-t', '=nt-rsk-eaten:', '-X', 'cancel']) + expect(drain('nt-rsk-eaten', out)).toBe('') + }) + + it('a session the host does not have is a failure — never created, never another node’s pane', () => { + // `nt-rsk-miss` does not exist; `nt-rsk-missing` does, and its name EXTENDS the target's. + const neighbour = recorderPane('nt-rsk-missing') + expect(sendOver('nt-rsk-miss', '1')).toBe(false) + expect(sessionExists('nt-rsk-miss')).toBe(false) + expect(drain('nt-rsk-missing', neighbour)).toBe('') + }) + + it('CONTROL: a target that is not exact PREFIX-matches the neighbour and exits 0', () => { + // Why the target is `'=:'`: the answer would land in another node's agent, reported sent. + const neighbour = recorderPane('nt-rsk-prefixed') + tmux(['-L', SOCKET, 'send-keys', '-t', 'nt-rsk-prefix', '-H', '31']) + expect(drain('nt-rsk-prefixed', neighbour)).toBe('1') + }) +}) + +/** The local manager's SAME delivery, using the private server rather than a desktop socket. */ +async function sendLocal(session: string, keys: string): Promise { + return sendBackgroundTmuxKeys(session, keys, async (args) => { + try { + const stdout = tmux(['-L', SOCKET, ...args]) + return { ok: true, body: stdout.trimEnd().split('\n') } + } catch { + return { ok: false, body: [] } + } + }) +} + +suite('REAL tmux: a local quick answer reaches the app rather than copy mode or another node', () => { + it('delivers digits, ESC and Enter byte for byte', async () => { + const session = 'nt-local-answer' + const out = recorderPane(session) + expect(await sendLocal(session, '2\u001b\r')).toBe(true) + expect(drain(session, out)).toBe('2\u001b\r') + }) + + it.each(['emacs', 'vi'])('leaves %s copy mode before typing an answer', async (keys) => { + const session = `nt-local-copy-${keys}` + const out = recorderPane(session) + tmux(['-L', SOCKET, 'set-option', '-w', '-t', `=${session}:`, 'mode-keys', keys]) + tmux(['-L', SOCKET, 'copy-mode', '-t', `=${session}:`]) + expect(await sendLocal(session, '3')).toBe(true) + expect(drain(session, out)).toBe('3') + expect(tmux(['-L', SOCKET, 'display-message', '-p', '-t', `=${session}:`, '#{pane_in_mode}']).trim()) + .toBe('0') + }) + + it('refuses a missing session whose name is a prefix of a real neighbour', async () => { + const out = recorderPane('nt-local-neighbour') + expect(await sendLocal('nt-local-neigh', '1')).toBe(false) + expect(drain('nt-local-neighbour', out)).toBe('') + }) + + it('keeps the original pane when desktop selection changes after its probe', async () => { + const session = 'nt-local-switch' + const out = recorderPane(session) + const original = tmux(['-L', SOCKET, 'display-message', '-p', '-t', `=${session}:`, '#{pane_id}']).trim() + const otherOut = path.join(work, `${session}.other.bytes`) + const other = tmux([ + '-L', SOCKET, 'split-window', '-d', '-P', '-F', '#{pane_id}', '-t', original, + `${binDir}/recorder ${otherOut}` + ]).trim() + waitFor(() => fs.existsSync(`${otherOut}.ready`), 'the other pane recorder') + tmux(['-L', SOCKET, 'copy-mode', '-t', original]) + + expect(await sendBackgroundTmuxKeys(session, '2', async (args) => { + const stdout = tmux(['-L', SOCKET, ...args]) + if (args[0] === 'display-message') tmux(['-L', SOCKET, 'select-pane', '-t', other]) + return { ok: true, body: stdout.trimEnd().split('\n') } + })).toBe(true) + expect(drain(session, out, original)).toBe('2') + expect(drain(session, otherOut, other)).toBe('') + }) +}) + + +suite('REAL tmux: offscreen wake keeps the previously verified pane identity', () => { + function identity(session: string): PaneOwner { + const [paneId, pid, command] = tmux(['-L', SOCKET, 'display-message', '-p', '-t', `=${session}:`, '#{pane_id} #{pane_pid} #{pane_current_command}']).trim().split(' ') + return { paneId, panePid: Number(pid), command, tty: '/dev/pts/1', argv: [] } + } + async function local(session: string, expected: PaneOwner): Promise { + return sendBackgroundTmuxKeys(session, 'wake', async (args) => { + try { return { ok: true, body: tmux(['-L', SOCKET, ...args]).trimEnd().split('\n') } } + catch { return { ok: false, body: [] } } + }, undefined, expected) + } + it('remote delivery succeeds for the same tuple and refuses changed pid, command or pane', () => { + const session = 'nt-wake-remote'; const out = recorderPane(session) + const owner = identity(session) + const run = (expected: PaneOwner): boolean => { + try { runRemote(remoteTmuxSendKeysArgs(CONN, '/cm.sock', session, 'wake', expected).at(-1)!); return true } + catch { return false } + } + expect(run(owner)).toBe(true) + for (const changed of [{ ...owner, panePid: owner.panePid + 1 }, { ...owner, command: 'stale' }, { ...owner, paneId: '%999999' }]) expect(run(changed)).toBe(false) + expect(drain(session, out)).toBe('wake') + }) + it('local delivery refuses the wrong final pid or command and sends nothing into that pane', async () => { + const session = 'nt-wake-local'; const out = recorderPane(session) + const owner = identity(session) + expect(await local(session, { ...owner, panePid: owner.panePid + 1 })).toBe(false) + expect(await local(session, { ...owner, command: 'stale' })).toBe(false) + expect(await local(session, { ...owner, paneId: '%999999' })).toBe(false) + expect(await local(session, owner)).toBe(true) + expect(drain(session, out)).toBe('wake') + }) +}) diff --git a/src/core/remote-ssh/control-master.test.ts b/src/core/remote-ssh/control-master.test.ts index c1ca6a104b..189d17d247 100644 --- a/src/core/remote-ssh/control-master.test.ts +++ b/src/core/remote-ssh/control-master.test.ts @@ -6,6 +6,7 @@ import { childArgs, remoteTmuxPasteArgs, remoteTmuxEnterArgs, + remoteTmuxSendKeysArgs, probeSaysAbsent, remoteCapturePaneArgs, remoteCaptureVisibleArgs, @@ -252,6 +253,51 @@ describe('remoteTmuxEnterArgs', () => { }) }) +// The relay phone's quick answers (`node.sendKeys`) for a node of an SSH project. The bytes are +// run against a real tmux in `remote-send-keys.realtmux.test.ts`; this pins the line's shape. +describe('remoteTmuxSendKeysArgs', () => { + const line = (session: string, data: string): string => + remoteTmuxSendKeysArgs(conn, '/s.sock', session, data).slice(-1)[0] + + it('is ONE remote tmux invocation over the master: gated copy-mode cancel, then the keys as hex', () => { + expect(remoteTmuxSendKeysArgs(conn, '/s.sock', 'nt-x', '1')).toEqual([ + ...childPrefix, + 'deploy@h.example.com', + `${TP}tmux -L ${RMT_TMUX_SOCKET} ` + + `if-shell -F -t '=nt-x:' '#{pane_in_mode}' 'send-keys -t =nt-x: -X cancel' ';' ` + + `send-keys -t '=nt-x:' -H 31` + ]) + }) + + it('sends ESC and Enter as the raw bytes a key press is — the same hex the local write uses', () => { + expect(line('nt-x', '\u001b')).toMatch(/ -H 1b$/) + expect(line('nt-x', '\r')).toMatch(/ -H 0d$/) + expect(line('nt-x', '2\r')).toMatch(/ -H 32 0d$/) + }) + + // Only hex reaches the remote shell and tmux's parser: a quote, a `;` (tmux's separator) or a + // leading dash in the answer cannot change the command. + it('carries no answer text on the line — only hex digits', () => { + const l = line('nt-x', `-R';$(id)`) + expect(l).toMatch(/ -H 2d 52 27 3b 24 28 69 64 29$/) + expect(l).not.toContain('$(id)') + }) + + // Without `=`, tmux PREFIX-matches on a miss: the answer would land in `nt-xy`'s agent. + it('targets the pane EXACTLY, on both the cancel and the keys', () => { + const l = line('nt-x', '1') + expect(l).toContain(`-t '=nt-x:'`) + expect(l).not.toMatch(/-t nt-x[ ;]/) + }) + + it('refuses a session id this app did not generate, and an empty answer', () => { + expect(() => remoteTmuxSendKeysArgs(conn, '/s.sock', 'nt-x; kill-server', '1')).toThrow( + /unsafe tmux paste target/ + ) + expect(() => remoteTmuxSendKeysArgs(conn, '/s.sock', 'nt-x', '')).toThrow(/no keys/) + }) +}) + describe('remoteCapturePaneArgs', () => { it('captures the whole scrollback (-S -) when full', () => { const args = remoteCapturePaneArgs(conn, '/s.sock', 'nt-x', true) diff --git a/src/core/remote-ssh/control-master.ts b/src/core/remote-ssh/control-master.ts index 4f71826396..38914d2864 100644 --- a/src/core/remote-ssh/control-master.ts +++ b/src/core/remote-ssh/control-master.ts @@ -18,6 +18,8 @@ import { type PasteDelivery } from '../tmux-naming' import { sanitizePasteText } from '../paste-injection' +import { encodeSendKeysHex } from '../tmux-control' +import type { PaneOwner } from '../../shared/agents/pane-owner-predicate' import { typedInputScript } from '../typed-input' import { canControlCanvas } from '../../shared/agents/config' import { COMBINED_PANE_MARKER, PANE_OWNER_FMT, PS_FOREGROUND_FLAGS } from '../agents/pane-owner' @@ -403,6 +405,55 @@ export function remoteTmuxEnterArgs( return childArgs(conn, controlPath, tmuxCmd(`tmux -L ${RMT_TMUX_SOCKET} send-keys -t ${sessionId} Enter`)) } +/** + * Type a few KEYS into a node's REMOTE tmux pane, over the project's ControlMaster — the remote + * counterpart of `PtyManager.backgroundWrite`, for the relay phone's quick answers + * (`node.sendKeys`: a question digit, a legacy approve `1`, a deny ESC, an Enter). Keys, not a + * paste: a digit pasted inside a bracketed-paste frame is TEXT to a TUI's picker, not a choice, and + * `sanitizePasteText` would strip the ESC that a Deny is. + * + * The bytes ride `send-keys -H` (the hex encoding the local background write already uses, + * `encodeSendKeysHex`), so only `[0-9a-f ]` reaches the remote shell and tmux's own parser: no + * quoting, no leading-dash hazard (the deleted `localTmuxSendKeysArgs`), and no lone `;` read as a + * command separator. They are a short answer by contract (`SEND_KEYS_RE` caps it at 16 bytes), so + * argv is the right channel — nothing here is a payload worth stdin, and nothing is a secret. + * + * Two rules, both MEASURED on tmux 3.4 (`remote-send-keys.realtmux.test.ts`): + * - **The target is exact, `'=:'`.** Without `=`, tmux falls back to PREFIX matching on a + * miss: `send-keys -t nt-ab` with no `nt-ab` typed into `nt-abc` and exited 0. The answer would + * land in another node's agent and report success. Exact, a missing session exits 1 — which is + * the caller's `sent:false`. (A PANE target needs the trailing colon; `=name` alone is refused.) + * - **Copy mode is cancelled first**, exactly as `remoteTmuxPasteArgs` does: a pane in copy mode + * (someone scrolled up on the desktop) hands `send-keys` to the copy-mode key table, so the key + * never reaches the app — and with emacs keys (tmux's default) tmux still exits 0: a delivery + * reported that never happened. + */ +export function remoteTmuxSendKeysArgs( + conn: SshConnection, + controlPath: string, + sessionId: string, + data: string, + expectedOwner?: PaneOwner +): string[] { + assertPasteTarget(sessionId) + if (data.length === 0) throw new Error('remoteTmuxSendKeysArgs: no keys to send') + const selected = `=${sessionId}:` + let pane = selected + let guard = '' + if (expectedOwner) { + if (!expectedOwner.paneId || !/^%[0-9]+$/.test(expectedOwner.paneId) || !Number.isSafeInteger(expectedOwner.panePid) || expectedOwner.panePid <= 0) throw new Error('remote wake: missing pane identity') + pane = expectedOwner.paneId + const tuple = `${pane} ${expectedOwner.panePid} ${expectedOwner.command}` + // A window switch or replaced session must not redirect a delayed wake into another pane. + guard = `[ "$(tmux -L ${RMT_TMUX_SOCKET} display-message -p -t ${posixQuote(selected)} ${posixQuote('#{pane_id} #{pane_pid} #{pane_current_command}')})" = ${posixQuote(tuple)} ] && ` + } + const cmd = guard + + `tmux -L ${RMT_TMUX_SOCKET} ` + + `if-shell -F -t '${pane}' '#{pane_in_mode}' 'send-keys -t ${pane} -X cancel' ';' ` + + encodeSendKeysHex(`'${pane}'`, data) + return childArgs(conn, controlPath, tmuxCmd(cmd)) +} + /** `delete-buffer` on the REMOTE server — the sweep for a remote paste that never ran. */ export function remoteDeleteBufferArgs( conn: SshConnection, @@ -541,6 +592,20 @@ export function remoteCapturePaneArgs(conn: SshConnection, controlPath: string, tmuxCmd(`tmux -L ${RMT_TMUX_SOCKET} capture-pane -p -e -t ${sessionId} -S ${full ? '-' : '-200'}`) ) } +/** Full plain history of an exact session's pinned pane; capture failures propagate. */ +export function remoteHistoryCaptureArgs(conn: SshConnection, controlPath: string, sessionId: string): string[] { + assertPasteTarget(sessionId) + return childArgs(conn, controlPath, tmuxCmd( + `nt_history_pane=$(tmux -L ${RMT_TMUX_SOCKET} display-message -p -t ${posixQuote(`=${sessionId}:`)} '#{pane_id}') || exit $?; ` + + `case "$nt_history_pane" in %*) case "${'$'}{nt_history_pane#%}" in ''|*[!0-9]*) exit 2;; esac;; *) exit 2;; esac; ` + + `tmux -L ${RMT_TMUX_SOCKET} capture-pane -p -J -t "$nt_history_pane" -S -` + )) +} +/** The VISIBLE pane of a remote session, with colours — the relay attach snapshot (no history: + * the client paints it as the current screen before live output). */ +export function remoteCaptureScreenArgs(conn: SshConnection, controlPath: string, sessionId: string): string[] { + return childArgs(conn, controlPath, tmuxCmd(`tmux -L ${RMT_TMUX_SOCKET} capture-pane -p -e -t ${sessionId}`)) +} /** * The VISIBLE screen of a remote session, with SGR, and nothing above it — a live link's keyframe * must never carry history, and `remoteCapturePaneArgs` always adds `-S`. The cursor line rides the diff --git a/src/core/session-host-backend.ts b/src/core/session-host-backend.ts index 821497f6af..054eabddbd 100644 --- a/src/core/session-host-backend.ts +++ b/src/core/session-host-backend.ts @@ -34,6 +34,10 @@ export async function sessionHostMessageEnvelope( return getClient().messageEnvelope(name, envelope, expected) } +export async function sessionHostWakeSleeping(name: string, data: string, expected: PaneOwner): Promise { + return getClient().wakeSleeping(name, data, expected) +} + let client: SessionHostClient | null = null function getClient(): SessionHostClient { @@ -131,6 +135,10 @@ export async function sessionHostCapture(name: string, full: boolean): Promise { + return getClient().historySearch(name, query) +} + /** Execute already-rendered trusted input through the persistent generation's exactly-once * launch ledger. The plan never crosses preload/renderer/relay boundaries. */ export async function sessionHostExecuteLaunch( diff --git a/src/core/session-host-client-geometry.test.ts b/src/core/session-host-client-geometry.test.ts index 17c1ac92b8..4a455c0dab 100644 --- a/src/core/session-host-client-geometry.test.ts +++ b/src/core/session-host-client-geometry.test.ts @@ -210,7 +210,9 @@ describe('SessionHostClient geometry (issue #914)', () => { const client = new SessionHostClient({ userDataDir }) await view(client, userDataDir, 80, 24) const hello = host.requests.find((request) => request.cmd === 'hello') - expect((hello as { features?: string[] }).features).toContain('geometry') + expect((hello as { features?: string[] }).features).toEqual( + expect.arrayContaining(['geometry', 'composed-input-v1', 'scroll-view-v1', 'shutdown']) + ) }) it('gives the session to a view that types, but not to one that only answers a query', async () => { diff --git a/src/core/session-host-client.test.ts b/src/core/session-host-client.test.ts index 71500c59e1..3b7a7c5097 100644 --- a/src/core/session-host-client.test.ts +++ b/src/core/session-host-client.test.ts @@ -196,6 +196,25 @@ afterEach(async () => { }) describe('SessionHostClient handshake transition', () => { + it('binds history search to a confirmed generation and leaves an old refusing host usable', async () => { + const { userDataDir, paths } = createUserData('old-host-history-refusal') + const commands: string[] = [] + let historyRequest: SessionHostRequest | undefined + await serve(paths.endpoint, (request, socket) => { + commands.push(request.cmd) + if (request.cmd === 'hello') socket.write(acceptedHello(request.id)) + else if (request.cmd === 'hasSession') socket.write(encodeFrame({ id: request.id, ok: true, result: { exists: true, generation: 'kept-generation' } })) + else { historyRequest = request; socket.write(encodeFrame({ id: request.id, ok: false, error: 'unknown command' })) } + }) + const client = new SessionHostClient({ userDataDir, repoRoot: userDataDir }) + await expect(within(client.historySearch('nt-existing', 'needle'))).rejects.toThrow(/No confirmed/) + expect(commands).toEqual(['hello']) + expect(await within(client.hasSession('nt-existing'))).toBe(true) + await expect(within(client.historySearch('nt-existing', 'needle'))).rejects.toThrow('unknown command') + expect(historyRequest).toMatchObject({ cmd: 'historySearchV1', name: 'nt-existing', generation: 'kept-generation', query: 'needle' }) + expect(await within(client.hasSession('nt-existing'))).toBe(true) + expect(commands).toEqual(['hello', 'hasSession', 'historySearchV1', 'hasSession']) + }) it('keeps an old live host usable when it refuses the additive messaging extension', async () => { const { userDataDir, paths } = createUserData('old-host-message-refusal') const commands: string[] = [] @@ -213,10 +232,32 @@ describe('SessionHostClient handshake transition', () => { panePid: 42, tty: 'win32-console:42', command: 'opencode', argv: ['opencode'] }))).toBe(false) expect(await within(client.hasSession('nt-existing'))).toBe(true) - expect(commands).toEqual(['hello', 'messageOwnerV1', 'messagePasteReadyV1', 'messageEnvelopeV1', 'hasSession']) + expect(await within(client.wakeSleeping('nt-existing', '\x1bcodex resume saved\r', { + panePid: 42, tty: 'win32-console:42', command: 'pwsh', argv: ['pwsh'] + }))).toBe(false) + expect(commands).toEqual(['hello', 'messageOwnerV1', 'messagePasteReadyV1', 'messageEnvelopeV1', 'hasSession', 'wakeSleepingV1']) // No fallback to sendKeys/write/executeLaunch, no kill, no replacement/attach request. }) + it('sends the confirmed backend generation and owner without a name-only wake fallback', async () => { + const { userDataDir, paths } = createUserData('generation-wake') + const requests: SessionHostRequest[] = [] + await serve(paths.endpoint, (request, socket) => { + requests.push(request) + if (request.cmd === 'hello') socket.write(acceptedHello(request.id)) + else if (request.cmd === 'hasSession') socket.write(encodeFrame({ id: request.id, ok: true, result: { exists: true, generation: 'observed' } })) + else socket.write(encodeFrame({ id: request.id, ok: true, result: true })) + }) + const client = new SessionHostClient({ userDataDir, repoRoot: userDataDir }) + const expected = { panePid: 42, tty: 'win32-console:42', paneId: 'win32:observed:birth', command: 'pwsh', argv: ['pwsh'], pids: [42], processBirths: ['birth'] } + expect(await within(client.wakeSleeping('nt-existing', '\x1bcodex resume saved\r', expected))).toBe(false) + expect(requests).toEqual([]) // No confirmed generation: never create or adopt one to wake. + expect(await within(client.hasSession('nt-existing'))).toBe(true) + expect(await within(client.wakeSleeping('nt-existing', '\x1bcodex resume saved\r', expected))).toBe(true) + expect(requests.map((r) => r.cmd)).toEqual(['hello', 'hasSession', 'wakeSleepingV1']) + expect(requests[2]).toMatchObject({ generation: 'observed', expected, data: '\x1bcodex resume saved\r' }) + }) + it.each([true, false, 'pasted-not-submitted', undefined] as const)('preserves sendKeysV2 outcome %s', async (delivery) => { const { userDataDir, paths } = createUserData('text-delivery-result') const commands: string[] = [] diff --git a/src/core/session-host-client.ts b/src/core/session-host-client.ts index 4518824398..822ddfa867 100644 --- a/src/core/session-host-client.ts +++ b/src/core/session-host-client.ts @@ -45,6 +45,37 @@ export const STAGED_RUNTIME_WAIT_MS = 45_000 import { latestClaimSize, type SizeClaim } from './pty-size' import { isTerminalReport } from './terminal-reports' import type { PreparedAgentLaunch } from './agent-launch' +import type { HistorySearch } from './terminal-history' +import { retryInterruptedSessionRead } from './session-host-read-retry' +import { waitForComposedEnter, type ComposedPrepareResult, type ComposedWriteResult } from './composed-pty' +import { COMPOSED_INPUT_UNCERTAIN, COMPOSED_INPUT_UNSUPPORTED, parseComposedInput, + type ComposedInput, type ComposedInputResult } from '../shared/composed-input' +import { HISTORY_CAPTURE_MAX_BYTES, HISTORY_CAPTURE_MAX_ROWS, type NativeScrollResult } from '../shared/history-scroll' + +function nativeScrollResult(value: unknown): NativeScrollResult | null { + if (!value || typeof value !== 'object') return null + const result = value as Record + if (result.status === 'input') return { status: 'input' } + if ((result.status === 'refused' || result.status === 'uncertain') && typeof result.message === 'string' && + result.message.length > 0 && result.message.length <= 1024) return { status: result.status, message: result.message } + if (result.status !== 'history') return null + if (result.capture === undefined) return { status: 'history' } + if (!result.capture || typeof result.capture !== 'object') return null + const capture = result.capture as Record + if (!Number.isInteger(capture.cols) || (capture.cols as number) < 1 || (capture.cols as number) > 65535 || + !Number.isInteger(capture.viewportRows) || (capture.viewportRows as number) < 1 || (capture.viewportRows as number) > 200 || + typeof capture.olderTruncated !== 'boolean' || !Array.isArray(capture.rows) || capture.rows.length > HISTORY_CAPTURE_MAX_ROWS) return null + let bytes = 0 + for (const value of capture.rows) { + if (!value || typeof value !== 'object' || typeof value.text !== 'string' || typeof value.isWrapped !== 'boolean' || + (value.section !== 'normal' && value.section !== 'alternate')) return null + bytes += Buffer.byteLength(value.text, 'utf8') + if (bytes > HISTORY_CAPTURE_MAX_BYTES) return null + } + if (Buffer.byteLength(JSON.stringify(capture), 'utf8') > HISTORY_CAPTURE_MAX_BYTES) return null + return { status: 'history', capture: { cols: capture.cols as number, viewportRows: capture.viewportRows as number, + olderTruncated: capture.olderTruncated, rows: capture.rows.map((row) => ({ text: row.text, isWrapped: row.isWrapped, section: row.section })) } } +} export interface SessionSubscriber { onData(data: string): void @@ -296,6 +327,8 @@ export class SessionHostClient { * size. On a host that predates it, the size this client last requested IS the pty's size as * long as this is the host's only connection — the common case, and the one issue #914 is. */ private hostGeometryEvents = false + private hostComposedInput = false + private hostScrollView = false /** The current connection negotiated `shutdown` (issue #829): only then may prepare-for-update * ask the host to end every session and exit. An older host lacks it, and the app then falls * back to the manual steps — never to killing the host itself. */ @@ -608,6 +641,8 @@ export class SessionHostClient { const helloId = this.nextId++ let protocolVersion: 1 | 2 | null = null let geometryEvents = false + let composedInput = false + let scrollView = false let shutdownFeature = false const finish = (ok: boolean, trailing: SessionHostFrame[] = []): void => { if (settled) return @@ -624,7 +659,7 @@ export class SessionHostClient { } this.hostShutdownFeature = shutdownFeature this.hostPid = Number.isInteger(identity.state.pid) ? identity.state.pid : null - this.attachSocket(socket, protocolVersion, geometryEvents) + this.attachSocket(socket, protocolVersion, geometryEvents, composedInput, scrollView) for (const frame of trailing) this.handleFrame(socket, frame) } else { try { @@ -720,6 +755,8 @@ export class SessionHostClient { protocolVersion = negotiated const features = (frame.result as HelloResult | undefined)?.features geometryEvents = Array.isArray(features) && features.includes('geometry') + composedInput = negotiated === 2 && Array.isArray(features) && features.includes('composed-input-v1') + scrollView = negotiated === 2 && Array.isArray(features) && features.includes('scroll-view-v1') shutdownFeature = Array.isArray(features) && features.includes('shutdown') finish(true, frames.slice(index + 1)) } else { @@ -740,10 +777,12 @@ export class SessionHostClient { }) } - private attachSocket(socket: net.Socket, protocolVersion: 1 | 2, geometryEvents: boolean): void { + private attachSocket(socket: net.Socket, protocolVersion: 1 | 2, geometryEvents: boolean, composedInput = false, scrollView = false): void { this.socket = socket this.negotiatedProtocolVersion = protocolVersion this.hostGeometryEvents = geometryEvents + this.hostComposedInput = composedInput + this.hostScrollView = scrollView this.everConnected = true const framer = new LineFramer() socket.on('data', (chunk: Buffer) => { @@ -772,6 +811,8 @@ export class SessionHostClient { this.socket = null this.negotiatedProtocolVersion = null this.hostGeometryEvents = false + this.hostComposedInput = false + this.hostScrollView = false for (const state of this.sessions.values()) { // The next connection re-learns the size from its own attach replies. state.hostGeometry = null @@ -945,32 +986,35 @@ export class SessionHostClient { request: SessionHostRequestBody, onSuccess?: (result: T, socket: net.Socket) => void, onSent?: () => void, + beforeSend?: () => boolean, timeoutMs = SESSION_HOST_REQUEST_TIMEOUT_MS ): Promise { // A peer-initiated close races the client's own 'close' event: a cached socket can look live // here while the peer already hung up, and a frame written into that gap fails (EPIPE) for // work the host never saw. requestOnSocket defers the actual write by one REAL event-loop // turn, so a raced hangup is discovered before any bytes are committed; such a frame is the - // one provably-undelivered case and the only one resent here on a fresh connection. A failure - // discovered by the write itself stays a plain rejection: by then the frame's delivery is the - // transport's business and other requests may already ride on it. - let undelivered: SessionHostRequestNotDeliveredError | undefined + // provably-undelivered case can be resent. A failure discovered by the write itself leaves + // delivery uncertain: only typed read-only operations may retry EPIPE/ECONNRESET. Sent writes, + // host refusals and deadlines remain plain rejections. + let previousFailure: Error | undefined for (let attempt = 0; ; attempt++) { try { await this.ensureConnected() } catch (connectError) { // A resend that cannot even reconnect reports the transport failure the caller actually // hit, not the follow-up connect refusal; a cold first connect keeps its own error. - throw undelivered?.original ?? connectError + throw previousFailure ?? connectError } const socket = this.socket if (!socket) throw new Error('session-host: not connected') try { - return await this.requestOnSocket(socket, request, onSuccess, onSent, timeoutMs) + return await this.requestOnSocket(socket, request, onSuccess, onSent, beforeSend, timeoutMs) } catch (error) { - if (!(error instanceof SessionHostRequestNotDeliveredError)) throw error - if (attempt + 1 >= SESSION_HOST_RESEND_ATTEMPTS) throw error.original - undelivered = error + const undelivered = error instanceof SessionHostRequestNotDeliveredError + if (!undelivered && !retryInterruptedSessionRead(request, error)) throw error + const original = undelivered ? error.original : asError(error) + if (attempt + 1 >= SESSION_HOST_RESEND_ATTEMPTS) throw original + previousFailure = original } } } @@ -982,6 +1026,7 @@ export class SessionHostClient { request: SessionHostRequestBody, onSuccess?: (result: T, socket: net.Socket) => void, onSent?: () => void, + beforeSend?: () => boolean, timeoutMs = SESSION_HOST_REQUEST_TIMEOUT_MS ): Promise { if (this.socket !== socket || socket.destroyed) { @@ -1025,6 +1070,16 @@ export class SessionHostClient { // because immediates run in scheduling order. realSetImmediate(() => { if (this.pending.get(id) !== pending) return + // Reconnect and the deferred send turn can both cross a viewer retirement. Reject this + // exact unwritten frame without dropping other subscribers or retrying a stale action. + try { + if (beforeSend && !beforeSend()) throw new SessionHostRequestRejectedError('terminal viewer changed or action unsupported') + } catch (error) { + this.pending.delete(id) + if (pending.timer) clearTimeout(pending.timer) + pending.reject(asError(error)) + return + } if (this.socket !== socket || socket.destroyed) { this.dropSocket(socket, new Error('session-host connection lost'), true) return @@ -1618,6 +1673,85 @@ export class SessionHostClient { } } + /** The explicit composer is bound to this subscriber registration and the generation it + * attached to. It never falls back to the name-only background sendKeys/write APIs. */ + async submitComposed(name: string, sub: SessionSubscriber, value: ComposedInput, current: () => boolean): Promise { + const input = parseComposedInput(value) + const state = this.sessions.get(name) + const entry = state?.entries.get(sub) + const generation = state?.generation + const refused = (): ComposedInputResult => ({ status: 'refused', message: + 'This terminal or Send action is no longer current, or another Send is pending. Reattach and check the terminal before sending again.' }) + const unsupported = (): ComposedInputResult => ({ status: 'refused', message: COMPOSED_INPUT_UNSUPPORTED }) + const uncertain = (): ComposedInputResult => ({ status: 'uncertain', message: COMPOSED_INPUT_UNCERTAIN }) + if (!input || !state || !entry || !generation) return refused() + let ownerSocket: net.Socket | undefined + const valid = (): boolean => current() && this.sessions.get(name) === state && + state.entries.get(sub) === entry && entry.phase === 'attached' && state.generation === generation && + !this.killReplayBarriers.has(name) && this.hostComposedInput && !!this.socket && + state.appliedSocket === this.socket && state.appliedAttached && (!ownerSocket || ownerSocket === this.socket) + let ticket: string | undefined + let pasteSent = false + let pasted = false + try { + const prepared = await this.request( + { cmd: 'prepareComposedV1', name, generation, input }, + (_result, socket) => { ownerSocket = socket }, undefined, valid) + if (prepared?.status !== 'prepared' || typeof prepared.ticket !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(prepared.ticket)) return refused() + ticket = prepared.ticket + const result = await this.request( + { cmd: 'writeComposedV1', name, generation, ticket, phase: 'paste' }, + undefined, () => { pasteSent = true }, valid) + if (result?.status === 'refused') return refused() + if (result?.status === 'delivered') return valid() ? { status: 'delivered' } : uncertain() + if (result?.status !== 'awaiting-enter') return uncertain() + pasted = true + await waitForComposedEnter() + if (!valid()) return uncertain() + const entered = await this.request( + { cmd: 'writeComposedV1', name, generation, ticket, phase: 'enter' }, undefined, undefined, valid) + return entered?.status === 'delivered' && valid() ? { status: 'delivered' } : uncertain() + } catch (error) { + // Only the structured phase result above proves a before-write refusal. Even an RPC error + // response can follow accepted input; every exception after a sent paste stays uncertain. + // Enter is never replayed after a possible paste. + return pasted || pasteSent ? uncertain() : this.socket && !this.socket.destroyed && !this.hostComposedInput ? unsupported() : refused() + } finally { + // Cancellation consumes only this one-use ticket on its original socket. It never opens a + // replacement transport, waits for cleanup, or writes terminal input. + if (ticket && ownerSocket && this.socket === ownerSocket && !ownerSocket.destroyed) { + void this.requestOnSocket(ownerSocket, { cmd: 'cancelComposedV1', name, generation, ticket }).catch(() => {}) + } + } + } + + /** A mixed history/input operation stays on its original attached socket, never replayed. */ + async scrollForHistory(name: string, sub: SessionSubscriber, up: boolean, lines: number, capture: boolean, + current: () => boolean): Promise { + const state = this.sessions.get(name), entry = state?.entries.get(sub), generation = state?.generation + const socket = this.socket + const refused = (): NativeScrollResult => ({ status: 'refused', message: 'This terminal viewer or scroll action is no longer current. Reattach before scrolling.' }) + const uncertain = (): NativeScrollResult => ({ status: 'uncertain', message: 'Wheel input may have reached the terminal. It was not sent again.' }) + if (!state || !entry || !generation || !socket || socket.destroyed || + typeof up !== 'boolean' || typeof capture !== 'boolean' || !Number.isInteger(lines) || lines < 1 || lines > 20) return refused() + const valid = (): boolean => current() && this.sessions.get(name) === state && state.entries.get(sub) === entry && + entry.phase === 'attached' && state.generation === generation && !this.killReplayBarriers.has(name) && + this.socket === socket && !socket.destroyed && state.appliedSocket === socket && state.appliedAttached + if (!valid()) return refused() + if (!this.hostScrollView) return { status: 'refused', message: 'Safe native history scrolling is unavailable on this host. Update nodeterm on the computer.' } + let sent = false + try { + const result = nativeScrollResult(await this.requestOnSocket(socket, + { cmd: 'scrollViewV1', name, generation, up, lines, capture }, undefined, () => { sent = true }, + () => valid() && this.hostScrollView)) + if (!result) return uncertain() + if (result.status === 'history') return valid() ? result : refused() + if (result.status === 'input') return valid() ? result : uncertain() + return result + } catch { return sent ? uncertain() : refused() } + } + async paneCommand(name: string): Promise { try { const result = await this.request({ cmd: 'paneCommand', name }) @@ -1645,11 +1779,27 @@ export class SessionHostClient { } catch { return false } } + async wakeSleeping(name: string, data: string, expected: PaneOwner): Promise { + const generation = this.sessions.get(name)?.generation ?? this.sessionGenerations.get(name) + if (!generation) return false + try { + return await this.request({ cmd: 'wakeSleepingV1', name, generation, data, expected }) === true + } catch { return false } // No ordinary write, opaque launch, retry, or legacy-host replacement. + } + async capture(name: string, full: boolean): Promise { const result = await this.request({ cmd: 'capture', name, full }) return result.text } + /** Additive extension; an older live host refuses it and is never restarted to enable search. */ + async historySearch(name: string, query: string): Promise { + await this.ensureConnected() + const generation = this.sessions.get(name)?.generation ?? this.sessionGenerations.get(name) + if (!generation) throw new Error(`No confirmed retained terminal generation for '${name}'.`) + return this.request({ cmd: 'historySearchV1', name, generation, query }) + } + /** Execute already-rendered trusted input through the persistent generation's exactly-once * launch ledger. Retrying with the SAME `launchId` after a lost reply is safe: the host answers * `already-executed` rather than typing the command a second time. Never retries internally — @@ -1947,6 +2097,7 @@ export class SessionHostClient { { cmd: 'shutdown' }, undefined, undefined, + undefined, options.timeoutMs ?? SHUTDOWN_REQUEST_TIMEOUT_MS ) } catch (error) { diff --git a/src/core/session-host-composed.test.ts b/src/core/session-host-composed.test.ts new file mode 100644 index 0000000000..62eb182639 --- /dev/null +++ b/src/core/session-host-composed.test.ts @@ -0,0 +1,180 @@ +import { EventEmitter } from 'node:events' +import net from 'node:net' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SessionHostClient, type SessionSubscriber } from './session-host-client' +import { SessionHostPty } from './session-host-pty' +import { sessionHostPaths } from '../session-host/paths' +import { encodeFrame, type SessionHostRequest, type SessionHostResponse } from '../session-host/protocol' + +const boundary = vi.hoisted(() => ({ identity: vi.fn(), spawn: vi.fn() })) +vi.mock('./session-host-launcher', () => ({ resolveSessionHostScript: () => null, spawnSessionHost: boundary.spawn })) +vi.mock('../session-host/existing-host-state', () => ({ readExistingSessionHostIdentity: boundary.identity, + startupLockState: () => 'other', LISTEN_RETRY_BUDGET_MS: 1000, EMPTY_LOCK_STALE_MS: 15000 })) +const generation = 'retained-generation-a', ticket = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' +const input = { kind: 'paste', text: 'retained complete draft', enter: true } as const +class Socket extends EventEmitter { + destroyed = false + requests: SessionHostRequest[] = [] + constructor(readonly feature = true, readonly act?: (request: SessionHostRequest, socket: Socket) => boolean) { super() } + unref() { return this } + write(data: string, cb?: (error?: Error) => void) { + const request = JSON.parse(data) as SessionHostRequest; this.requests.push(request); cb?.() + if (this.act?.(request, this)) return true + const result = request.cmd === 'hello' ? { protocolVersion: 2, features: this.feature ? ['composed-input-v1'] : [] } + : request.cmd === 'attach' || request.cmd === 'attachExisting' ? { fresh: request.cmd === 'attach', generation } + : request.cmd === 'prepareComposedV1' ? { status: 'prepared', ticket } + : request.cmd === 'writeComposedV1' ? { status: request.phase === 'paste' ? 'awaiting-enter' : 'delivered' } + : {} + this.respond({ id: request.id, ok: true, result }); return true + } + respond(frame: SessionHostResponse) { queueMicrotask(() => { if (!this.destroyed) this.emit('data', Buffer.from(encodeFrame(frame))) }) } + destroy() { if (!this.destroyed) { this.destroyed = true; queueMicrotask(() => this.emit('close')) }; return this } +} +let sockets: Socket[], index: number +const viewers: Array<{ client: SessionHostClient; sub: SessionSubscriber }> = [] +beforeEach(() => { + sockets = []; index = 0; boundary.spawn.mockReset() + const paths = sessionHostPaths('/fixture/user-data') + boundary.identity.mockReturnValue({ kind: 'ready', state: { pid: 42, endpoint: paths.endpoint, + tokenPath: paths.tokenPath, startedAt: 1, protocolVersion: 2 }, token: 'a'.repeat(64) }) + vi.spyOn(net, 'connect').mockImplementation(() => { + const socket = sockets[index++]; if (!socket) throw new Error('unexpected extra transport') + queueMicrotask(() => socket.emit('connect')); return socket as unknown as net.Socket + }) +}) +afterEach(async () => { + viewers.splice(0).forEach(({ client, sub }) => client.unsubscribe('ours', sub)) + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)) + sockets.forEach((socket) => socket.destroy()); await Promise.resolve(); vi.restoreAllMocks() +}) +async function viewer(...hosts: Socket[]) { + sockets.push(...hosts) + const client = new SessionHostClient({ userDataDir: '/fixture/user-data' }) + const sub = { onData() {}, onExit() {} }; viewers.push({ client, sub }) + await client.attach('ours', { cwd: '/fixture', shell: 'fixture', args: [], env: {}, cols: 80, rows: 24 }, 100, sub) + return { client, sub } +} +/** Admission probe only: pause a real queued request before its real event-loop send turn. + * No response or expected delivery is manufactured from this private state. */ +async function queuedAfter(client: SessionHostClient, after = 0) { + for (let i = 0; i < 100; i++) { + const pending = (client as unknown as { pending: Map }).pending + if ([...pending].some(([id, value]) => id > after && !value.sent)) return + await Promise.resolve() + } + throw new Error('fixture failed to admit an unwritten request') +} + +describe('session-host composer captured-subscriber transport', () => { + it('keeps detached and failed-attachment shim refusals neutral rather than requesting an update', async () => { + for (const failed of [false, true]) { + const host = new Socket(true, (req, socket) => { + if (failed && req.cmd === 'attach') { socket.respond({ id: req.id, ok: false, error: 'fixture attach refused' }); return true } + return false + }) + sockets.push(host) + const client = new SessionHostClient({ userDataDir: '/fixture/user-data' }) + const painter = new SessionHostPty(client, 'ours', { cwd: '/fixture', shell: 'fixture', args: [], env: {}, cols: 80, rows: 24 }, 100) + try { + await painter.ready.catch(() => {}) + if (!failed) painter.destroy() + const result = await painter.submitComposed(input, () => true) + expect(result.status).toBe('refused'); expect(result.message).toContain('Reattach') + expect(result.message).not.toContain('Update nodeterm') + expect(host.requests.some((r) => r.cmd.includes('Composed'))).toBe(false) + } finally { painter.destroy() } + } + }) + it('refuses an older live host without new input commands or a host restart', async () => { + const old = new Socket(false), { client, sub } = await viewer(old) + expect((await client.submitComposed('ours', sub, input, () => true)).status).toBe('refused') + expect(old.requests.filter((r) => r.cmd.includes('Composed'))).toEqual([]) + expect(boundary.spawn).not.toHaveBeenCalled(); expect(index).toBe(1) + }) + it('rechecks stream retirement inside the deferred paste-send turn', async () => { + let prepare!: SessionHostRequest + const host = new Socket(true, (req) => { if (req.cmd === 'prepareComposedV1') { prepare = req; return true }; return false }) + const { client, sub } = await viewer(host); let alive = true + const sending = client.submitComposed('ours', sub, input, () => alive) + while (!prepare) await new Promise((resolve) => setImmediate(resolve)) + host.respond({ id: prepare.id, ok: true, result: { status: 'prepared', ticket } }) + await queuedAfter(client, prepare.id); alive = false + expect((await sending).status).toBe('refused') + expect(host.requests.filter((r) => r.cmd === 'writeComposedV1')).toEqual([]) + }) + it('describes a negotiated host busy refusal as a current action refusal, not an upgrade requirement', async () => { + const host = new Socket(true, (req, socket) => { + if (req.cmd === 'prepareComposedV1') { + socket.respond({ id: req.id, ok: true, result: { status: 'refused', message: 'Another Send is pending.' } }); return true + } + return false + }) + const { client, sub } = await viewer(host) + const result = await client.submitComposed('ours', sub, input, () => true) + expect(result.status).toBe('refused'); expect(result.message).toContain('pending') + expect(result.message).not.toContain('Update nodeterm') + expect(host.requests.filter((r) => r.cmd === 'writeComposedV1')).toEqual([]) + }) + it('does not call a failed reconnect proof of a missing host capability', async () => { + const host = new Socket(), { client, sub } = await viewer(host) + host.destroy(); await new Promise((resolve) => setImmediate(resolve)) + const result = await client.submitComposed('ours', sub, input, () => true) + expect(result.status).toBe('refused'); expect(result.message).toContain('Reattach') + expect(result.message).not.toContain('Update nodeterm') + expect(host.requests.some((r) => r.cmd.includes('Composed'))).toBe(false) + }) + it('checks the captured entry again after reconnecting a provably undelivered preparation', async () => { + let alive = true + const first = new Socket(), second = new Socket(true, (req) => { + if (req.cmd === 'attachExisting') alive = false + return false + }) + const { client, sub } = await viewer(first, second) + const sending = client.submitComposed('ours', sub, input, () => alive) + await queuedAfter(client); first.destroy() + expect((await sending).status).toBe('refused') + expect(index).toBe(2) + expect([...first.requests, ...second.requests].filter((r) => r.cmd === 'prepareComposedV1' || r.cmd === 'writeComposedV1')).toEqual([]) + }) + it('does not redeem an original socket ticket over a reattached same-generation socket', async () => { + let prepare!: SessionHostRequest + const first = new Socket(true, (req) => { if (req.cmd === 'prepareComposedV1') { prepare = req; return true }; return false }) + const second = new Socket(), { client, sub } = await viewer(first, second) + const sending = client.submitComposed('ours', sub, input, () => true) + while (!prepare) await new Promise((resolve) => setImmediate(resolve)) + first.respond({ id: prepare.id, ok: true, result: { status: 'prepared', ticket } }) + await queuedAfter(client, prepare.id); first.destroy() + expect((await sending).status).toBe('refused') + expect(index).toBe(2) + expect(second.requests.some((r) => r.cmd === 'writeComposedV1')).toBe(false) + }) + it('keeps a lost transmitted paste uncertain and never replays or submits Enter', async () => { + const host = new Socket(true, (req, socket) => { + if (req.cmd === 'writeComposedV1' && req.phase === 'paste') { socket.destroy(); return true }; return false + }) + const { client, sub } = await viewer(host) + expect((await client.submitComposed('ours', sub, input, () => true)).status).toBe('uncertain') + client.unsubscribe('ours', sub) + expect(host.requests.filter((r) => r.cmd === 'writeComposedV1')).toHaveLength(1) + expect(host.requests.some((r) => r.cmd === 'write' || r.cmd === 'sendKeysV2')).toBe(false) + expect(index).toBe(1) + }) + it('does not reinterpret a server RPC error after a transmitted paste as a proven refusal', async () => { + const host = new Socket(true, (req, socket) => { + if (req.cmd === 'writeComposedV1' && req.phase === 'paste') { + socket.respond({ id: req.id, ok: false, error: 'reply failed after native write' }); return true + } + return false + }) + const { client, sub } = await viewer(host) + expect((await client.submitComposed('ours', sub, input, () => true)).status).toBe('uncertain') + expect(host.requests.filter((r) => r.cmd === 'writeComposedV1')).toHaveLength(1) + }) + it('does not let an unsubscribed registration inherit an action', async () => { + const host = new Socket(), { client, sub } = await viewer(host) + const sending = client.submitComposed('ours', sub, input, () => true) + await queuedAfter(client); client.unsubscribe('ours', sub) + expect((await sending).status).toBe('refused') + expect(host.requests.filter((r) => r.cmd === 'writeComposedV1')).toEqual([]) + }) +}) diff --git a/src/core/session-host-history-scroll.test.ts b/src/core/session-host-history-scroll.test.ts new file mode 100644 index 0000000000..c04b58ed6e --- /dev/null +++ b/src/core/session-host-history-scroll.test.ts @@ -0,0 +1,171 @@ +import { EventEmitter } from 'node:events' +import net from 'node:net' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SessionHostClient, type SessionSubscriber } from './session-host-client' +import { SessionHostPty } from './session-host-pty' +import { sessionHostPaths } from '../session-host/paths' +import { encodeFrame, type SessionHostRequest, type SessionHostResponse } from '../session-host/protocol' + +const boundary = vi.hoisted(() => ({ identity: vi.fn(), spawn: vi.fn() })) +vi.mock('./session-host-launcher', () => ({ resolveSessionHostScript: () => null, spawnSessionHost: boundary.spawn })) +vi.mock('../session-host/existing-host-state', () => ({ readExistingSessionHostIdentity: boundary.identity, + startupLockState: () => 'other', LISTEN_RETRY_BUDGET_MS: 1000, EMPTY_LOCK_STALE_MS: 15000 })) +const generation = 'retained-generation-a' +const capture = { cols: 80, viewportRows: 24, olderTruncated: false, + rows: [{ text: 'retained pre-attach history', isWrapped: false, section: 'normal' }] } +/** Socket recorder boundary; the production client owns negotiation, subscriptions and sends. */ +class Socket extends EventEmitter { + destroyed = false + requests: SessionHostRequest[] = [] + constructor(readonly feature = true, readonly act?: (request: SessionHostRequest, socket: Socket) => boolean) { super() } + unref() { return this } + write(data: string, cb?: (error?: Error) => void) { + const request = JSON.parse(data) as SessionHostRequest; this.requests.push(request); cb?.() + if (this.act?.(request, this)) return true + const result = request.cmd === 'hello' ? { protocolVersion: 2, features: this.feature ? ['scroll-view-v1'] : [] } + : request.cmd === 'attach' || request.cmd === 'attachExisting' ? { fresh: request.cmd === 'attach', generation } + : request.cmd === 'scrollViewV1' ? { status: 'history', ...(request.capture ? { capture } : {}) } : {} + this.respond({ id: request.id, ok: true, result }); return true + } + respond(frame: SessionHostResponse) { queueMicrotask(() => { if (!this.destroyed) this.emit('data', Buffer.from(encodeFrame(frame))) }) } + destroy() { if (!this.destroyed) { this.destroyed = true; queueMicrotask(() => this.emit('close')) }; return this } +} +let sockets: Socket[], index: number +const viewers: Array<{ client: SessionHostClient; sub: SessionSubscriber }> = [] +beforeEach(() => { + sockets = []; index = 0; boundary.spawn.mockReset() + const paths = sessionHostPaths('/fixture/user-data') + boundary.identity.mockReturnValue({ kind: 'ready', state: { pid: 42, endpoint: paths.endpoint, + tokenPath: paths.tokenPath, startedAt: 1, protocolVersion: 2 }, token: 'a'.repeat(64) }) + vi.spyOn(net, 'connect').mockImplementation(() => { + const socket = sockets[index++]; if (!socket) throw new Error('unexpected extra transport') + queueMicrotask(() => socket.emit('connect')); return socket as unknown as net.Socket + }) +}) +afterEach(async () => { + viewers.splice(0).forEach(({ client, sub }) => client.unsubscribe('ours', sub)) + await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)) + sockets.forEach((socket) => socket.destroy()); await Promise.resolve(); vi.restoreAllMocks() +}) +async function viewer(...hosts: Socket[]) { + sockets.push(...hosts) + const client = new SessionHostClient({ userDataDir: '/fixture/user-data' }) + const sub = { onData() {}, onExit() {} }; viewers.push({ client, sub }) + await client.attach('ours', { cwd: '/fixture', shell: 'fixture', args: [], env: {}, cols: 80, rows: 24 }, 100, sub) + return { client, sub } +} +async function queued(client: SessionHostClient) { + for (let i = 0; i < 100; i++) { + const pending = (client as unknown as { pending: Map }).pending + if ([...pending.values()].some((value) => !value.sent)) return + await Promise.resolve() + } + throw new Error('fixture failed to admit an unwritten request') +} +const scrollRequests = (socket: Socket) => socket.requests.filter((r) => r.cmd === 'scrollViewV1') + +describe('session-host scrolling on the original subscribed transport', () => { + it('sends exact generation and capture intent, returning bounded inert physical rows', async () => { + const host = new Socket(), { client, sub } = await viewer(host) + expect(await client.scrollForHistory('ours', sub, true, 3, true, () => true)).toEqual({ status: 'history', capture }) + expect(await client.scrollForHistory('ours', sub, false, 2, false, () => true)).toEqual({ status: 'history' }) + expect(scrollRequests(host).map(({ id: _id, ...body }) => body)).toEqual([ + { cmd: 'scrollViewV1', name: 'ours', generation, up: true, lines: 3, capture: true }, + { cmd: 'scrollViewV1', name: 'ours', generation, up: false, lines: 2, capture: false } + ]) + }) + it('only describes a negotiated missing feature as an update requirement', async () => { + const host = new Socket(false), { client, sub } = await viewer(host) + const old = await client.scrollForHistory('ours', sub, true, 1, true, () => true) + expect(old.status).toBe('refused'); if (old.status === 'refused') expect(old.message).toContain('Update nodeterm') + expect(scrollRequests(host)).toEqual([]); expect(boundary.spawn).not.toHaveBeenCalled() + host.destroy(); await new Promise((resolve) => setImmediate(resolve)) + const lost = await client.scrollForHistory('ours', sub, true, 1, true, () => true) + expect(lost.status).toBe('refused'); if (lost.status === 'refused') expect(lost.message).not.toContain('Update nodeterm') + expect(scrollRequests(host)).toEqual([]) + }) + it('rechecks viewer lifetime, registration and generation in the deferred send turn', async () => { + for (const change of ['current', 'entry', 'generation', 'barrier'] as const) { + const host = new Socket(), { client, sub } = await viewer(host); let current = true + const action = client.scrollForHistory('ours', sub, true, 1, true, () => current) + await queued(client) + const internals = client as unknown as { sessions: Map; generation: string }>; killReplayBarriers: Set } + if (change === 'current') current = false + if (change === 'entry') internals.sessions.get('ours')!.entries.set(sub, { ...(internals.sessions.get('ours')!.entries.get(sub) as object) }) + if (change === 'generation') internals.sessions.get('ours')!.generation = 'replacement-generation' + if (change === 'barrier') internals.killReplayBarriers.add('ours') + expect((await action).status).toBe('refused'); expect(scrollRequests(host)).toEqual([]) + } + }) + it('does not replay an unwritten mixed scroll after replacing the socket', async () => { + const first = new Socket(), second = new Socket(), { client, sub } = await viewer(first, second) + const action = client.scrollForHistory('ours', sub, true, 1, true, () => true) + await queued(client); first.destroy() + expect((await action).status).toBe('refused') + await new Promise((resolve) => setImmediate(resolve)) + expect(scrollRequests(first)).toEqual([]); expect(scrollRequests(second)).toEqual([]) + }) + it('keeps a lost transmitted wheel or negative RPC uncertain without a second frame', async () => { + for (const reply of ['lost', 'error'] as const) { + const host = new Socket(true, (req, socket) => { + if (req.cmd !== 'scrollViewV1') return false + if (reply === 'lost') socket.destroy() + else socket.respond({ id: req.id, ok: false, error: 'native write reply lost' }) + return true + }) + const { client, sub } = await viewer(host) + expect((await client.scrollForHistory('ours', sub, true, 3, false, () => true)).status).toBe('uncertain') + client.unsubscribe('ours', sub) + expect(scrollRequests(host)).toHaveLength(1) + expect(host.requests.some((r) => r.cmd === 'write' || r.cmd === 'sendKeysV2')).toBe(false) + } + }) + it('rejects malformed or over-budget captures without interpreting them as successful history', async () => { + const bodies = [ + { status: 'input-ish' }, { status: 'refused', message: '' }, + { status: 'history', capture: { ...capture, viewportRows: 201 } }, + { status: 'history', capture: { ...capture, rows: [{ text: 'bad', isWrapped: false, section: 'foreign' }] } }, + { status: 'history', capture: { ...capture, rows: Array.from({ length: 8193 }, () => capture.rows[0]) } }, + { status: 'history', capture: { ...capture, rows: [{ ...capture.rows[0], text: '\"'.repeat(600_000) }] } } + ] + for (const body of bodies) { + const host = new Socket(true, (req, socket) => { + if (req.cmd !== 'scrollViewV1') return false + socket.respond({ id: req.id, ok: true, result: body }); return true + }) + const { client, sub } = await viewer(host) + expect((await client.scrollForHistory('ours', sub, true, 1, true, () => true)).status).toBe('uncertain') + expect(scrollRequests(host)).toHaveLength(1) + } + }) + it('drops a late history answer and treats a late input acknowledgement as uncertain after retirement', async () => { + for (const status of ['history', 'input'] as const) { + let held!: SessionHostRequest, entered!: () => void + const admitted = new Promise((done) => { entered = done }) + const host = new Socket(true, (req) => { if (req.cmd !== 'scrollViewV1') return false; held = req; entered(); return true }) + const { client, sub } = await viewer(host); let current = true + const action = client.scrollForHistory('ours', sub, true, 1, true, () => current) + await admitted; current = false + host.respond({ id: held.id, ok: true, result: { status, ...(status === 'history' ? { capture } : {}) } }) + expect((await action).status).toBe(status === 'history' ? 'refused' : 'uncertain') + } + }) + it('keeps detached or failed-attachment shim actions neutral and sends no scroll', async () => { + for (const failed of [false, true]) { + const host = new Socket(true, (req, socket) => { + if (failed && req.cmd === 'attach') { socket.respond({ id: req.id, ok: false, error: 'attach refused' }); return true } + return false + }) + sockets.push(host) + const client = new SessionHostClient({ userDataDir: '/fixture/user-data' }) + const painter = new SessionHostPty(client, 'ours', { cwd: '/fixture', shell: 'fixture', args: [], env: {}, cols: 80, rows: 24 }, 100) + try { + await painter.ready.catch(() => {}) + if (!failed) painter.destroy() + const result = await painter.scrollForHistory(true, 1, true, () => true) + expect(result.status).toBe('refused'); if (result.status === 'refused') expect(result.message).not.toContain('Update nodeterm') + expect(scrollRequests(host)).toEqual([]) + } finally { painter.destroy() } + } + }) +}) diff --git a/src/core/session-host-pty.ts b/src/core/session-host-pty.ts index 92b089bdcd..9d62102b2a 100644 --- a/src/core/session-host-pty.ts +++ b/src/core/session-host-pty.ts @@ -7,6 +7,8 @@ import type { SessionHostClient, SessionSubscriber } from './session-host-client' import type { SessionHostSpawnOptions, AttachResult } from '../session-host/protocol' +import { COMPOSED_INPUT_UNCERTAIN, type ComposedInput, type ComposedInputResult } from '../shared/composed-input' +import type { NativeScrollResult } from '../shared/history-scroll' export class SessionHostPty { readonly name: string @@ -87,6 +89,27 @@ export class SessionHostPty { this.client.write(this.name, this.sub, data) } + async submitComposed(input: ComposedInput, current: () => boolean): Promise { + const valid = (): boolean => !this.detached && !this.attachError && current() + const refused = (): ComposedInputResult => ({ status: 'refused', message: 'This terminal viewer is no longer attached. Reattach before sending.' }) + if (!valid()) return refused() + try { + await this.ready + } catch { return refused() } + if (!valid()) return refused() + try { return await this.client.submitComposed(this.name, this.sub, input, valid) } + catch { return { status: 'uncertain', message: COMPOSED_INPUT_UNCERTAIN } } + } + + async scrollForHistory(up: boolean, lines: number, capture: boolean, current: () => boolean): Promise { + const valid = (): boolean => !this.detached && !this.attachError && current() + const refused = (): NativeScrollResult => ({ status: 'refused', message: 'This terminal viewer is no longer attached. Reattach before scrolling.' }) + if (!valid()) return refused() + try { await this.ready } catch { return refused() } + if (!valid()) return refused() + return this.client.scrollForHistory(this.name, this.sub, up, lines, capture, valid) + } + /** `bounding`: this pty's viewers cannot adapt to a grid other than their own, so the shared * session must never grow past this claim (see `latestClaimSize`). */ resize(cols: number, rows: number, bounding = false): void { diff --git a/src/core/session-host-read-retry.test.ts b/src/core/session-host-read-retry.test.ts new file mode 100644 index 0000000000..8a07793c41 --- /dev/null +++ b/src/core/session-host-read-retry.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { retryInterruptedSessionRead } from './session-host-read-retry' +import type { SessionHostRequestBody } from '../session-host/protocol' + +describe('session backend interrupted-read retry', () => { + const lost = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }) + it.each([ + { cmd: 'hasSession', name: 'ours' }, { cmd: 'capture', name: 'ours', full: true }, + { cmd: 'historySearchV1', name: 'ours', generation: 'our-generation', query: 'literal' }, + { cmd: 'listSessions' }, { cmd: 'paneCommand', name: 'ours' }, { cmd: 'messageOwnerV1', name: 'ours' }, { cmd: 'messagePasteReadyV1', name: 'ours' } + ])('allows bounded reads after EPIPE: $cmd', (request) => { expect(retryInterruptedSessionRead(request, lost)).toBe(true) }) + it('keeps writes, unknown failures, timeouts and host refusals non-retryable', () => { + const writes: SessionHostRequestBody[] = [ + { cmd: 'write', name: 'ours', data: 'input' }, { cmd: 'resize', name: 'ours', cols: 80, rows: 24 }, + { cmd: 'sendKeysV2', name: 'ours', text: 'answer', enter: true }, { cmd: 'pause', name: 'ours' }, + { cmd: 'resume', name: 'ours' }, { cmd: 'detach', name: 'ours' }, { cmd: 'killSession', name: 'ours', operationId: 'our-operation', reserveReplacement: false } + ] + for (const request of writes) expect(retryInterruptedSessionRead(request, lost)).toBe(false) + for (const error of [new Error('deadline'), new Error('host refused'), Object.assign(new Error('no access'), { code: 'EACCES' }), null]) { + expect(retryInterruptedSessionRead({ cmd: 'hasSession', name: 'ours' }, error)).toBe(false) + } + expect(retryInterruptedSessionRead({ cmd: 'hasSession', name: 'ours' }, Object.assign(new Error('reset'), { code: 'ECONNRESET' }))).toBe(true) + }) +}) diff --git a/src/core/session-host-read-retry.ts b/src/core/session-host-read-retry.ts new file mode 100644 index 0000000000..456cb31cc6 --- /dev/null +++ b/src/core/session-host-read-retry.ts @@ -0,0 +1,8 @@ +import type { SessionHostRequestBody } from '../session-host/protocol' + +/** Socket-write failures leave delivery uncertain. Only bounded reads may be sent again. */ +export function retryInterruptedSessionRead(request: SessionHostRequestBody, error: unknown): boolean { + const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined + if (code !== 'EPIPE' && code !== 'ECONNRESET') return false + return ['hasSession', 'capture', 'historySearchV1', 'listSessions', 'paneCommand', 'messageOwnerV1', 'messagePasteReadyV1'].includes(request.cmd) +} diff --git a/src/core/ssh-actions.test.ts b/src/core/ssh-actions.test.ts new file mode 100644 index 0000000000..5174385e7c --- /dev/null +++ b/src/core/ssh-actions.test.ts @@ -0,0 +1,419 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import nodeFs, { promises as fs } from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { initPlatform, resetPlatformForTests } from './platform' +import { fakePlatform } from './platform-fake' +import { WorkspaceStore, type RemoteWorkspaceIO } from './workspace-store' +import { readSshActionFile, SshActionsService, SSH_ACTIONS_MAX_BYTES, SSH_ACTIONS_RETRY_MS, safeSshActionStat, startSshActionsService } from './ssh-actions' +import type { Project, Workspace } from '../shared/types' + +let root: string +let cwd: string +let fake: ReturnType +let store: WorkspaceStore +let service: SshActionsService +const node = { id: 'term-one', kind: 'terminal', position: { x: 0, y: 0 }, size: { width: 1, height: 1 }, title: 'one', color: '#fff', group: null } as const +const project = (extra: Partial = {}): Project => ({ id: 'p1', name: 'one', color: '#fff', viewport: { x: 0, y: 0, zoom: 1 }, nodes: [node], ...extra }) +const workspace = (projects: Project[]): Workspace => ({ version: 2, activeProjectId: 'p1', projects }) +const read = async () => JSON.parse(await fs.readFile(path.join(cwd, '.nodeterm/project.json'), 'utf8')) +const times = new Map() +async function send(method: string, params: Record, nonce = randomUUID(), instance = service.instance) { + const issuedAt = times.get(nonce) ?? Date.now(); times.set(nonce, issuedAt) + const file = path.join(service.directory, nonce + '.request') + await fs.writeFile(file, JSON.stringify({ version: 1, instance, nonce, issuedAt, method, params }), { mode: 0o600 }) + await service.tick() + return JSON.parse(await readSshActionFile(path.join(service.directory, nonce + '.response'))) +} +beforeEach(async () => { + times.clear() + root = await fs.mkdtemp(path.join(os.tmpdir(), 'nt-ssh-actions-')) + cwd = path.join(root, 'project'); await fs.mkdir(cwd) + fake = fakePlatform({ userDataDir: root }); initPlatform(fake) + store = new WorkspaceStore(); await store.save(workspace([project({ cwd })])) + service = new SshActionsService(root, store) + await service.start() +}) +afterEach(async () => { + await service.stop() + vi.restoreAllMocks(); resetPlatformForTests() + await fs.rm(root, { force: true, recursive: true }) +}) + +describe('selected-profile SSH actions filesystem service', () => { + it('suppresses unavailable namespace discovery without breaking native startup', async () => { + if (process.platform !== 'linux') return + await service.stop() + vi.spyOn(nodeFs, 'readlinkSync').mockImplementation(() => { throw new Error('private proc is unavailable') }) + expect(await startSshActionsService(root, store)).toBeUndefined() + expect(nodeFs.existsSync(path.join(service.root, 'advertisement.json'))).toBe(false) + }) + it('retires monotonically during blocked startup and never publishes afterward', async () => { + await service.stop(); service = new SshActionsService(root, store) + let release!: () => void, entered!: () => void + const blocked = new Promise((r) => { release = r }), sawDirectory = new Promise((r) => { entered = r }) + const original = fs.lstat.bind(fs) + vi.spyOn(fs, 'lstat').mockImplementation(async (...args: any[]) => { + const stat = await original(...args as Parameters) + if (args[0] === service.directory) { entered(); await blocked } + return stat + }) + const starting = service.start(); await sawDirectory + const stopping = service.stop(); release() + await expect(starting).rejects.toThrow(/stopped during startup/); await stopping + vi.restoreAllMocks() + expect(nodeFs.existsSync(path.join(service.root, 'advertisement.json'))).toBe(false) + expect(nodeFs.existsSync(path.join(service.root, 'claim'))).toBe(false) + await expect(service.start()).rejects.toThrow(/already started or stopped/) + await service.tick() + expect(nodeFs.existsSync(path.join(service.root, 'advertisement.json'))).toBe(false) + }) + it('retires during post-publication cleanup without installing a polling timer', async () => { + await service.stop(); service = new SshActionsService(root, store) + let release!: () => void, entered!: () => void + const blocked = new Promise((r) => { release = r }), published = new Promise((r) => { entered = r }) + const original = fs.unlink.bind(fs) + vi.spyOn(fs, 'unlink').mockImplementation(async (...args: any[]) => { + if (String(args[0]).includes('advertisement.json.') && String(args[0]).endsWith('.tmp')) { entered(); await blocked } + return original(...args as Parameters) + }) + const timer = vi.spyOn(globalThis, 'setInterval') + const starting = service.start(); await published + expect(nodeFs.existsSync(path.join(service.root, 'advertisement.json'))).toBe(true) + const stopping = service.stop(); release() + await expect(starting).rejects.toThrow(/stopped during startup/); await stopping + expect(timer).not.toHaveBeenCalled() + vi.restoreAllMocks() + expect(nodeFs.existsSync(path.join(service.root, 'advertisement.json'))).toBe(false) + expect(nodeFs.existsSync(path.join(service.root, 'claim'))).toBe(false) + }) + it('refuses dispatch if its exclusive claim changes even while the old advertisement looks current', async () => { + const claim = path.join(service.root, 'claim/owner.json') + const owner = JSON.parse(await readSshActionFile(claim)); owner.instance = randomUUID() + await fs.writeFile(claim, JSON.stringify(owner), { mode: 0o600 }) + const nonce = randomUUID() + await fs.writeFile(path.join(service.directory, nonce + '.request'), JSON.stringify({ version: 1, instance: service.instance, nonce, issuedAt: Date.now(), method: 'projects.ensureBoard', params: { projectId: 'p1' } }), { mode: 0o600 }) + await service.tick() + expect((await read()).kanban).toBeUndefined() + expect(nodeFs.existsSync(path.join(service.directory, nonce + '.response'))).toBe(false) + await service.stop() + expect(JSON.parse(await readSshActionFile(claim)).instance).toBe(owner.instance) + }) + it('rejects a repeated start without retiring its existing active owner', async () => { + const before = await readSshActionFile(path.join(service.root, 'advertisement.json')) + await expect(service.start()).rejects.toThrow(/already started or stopped/) + expect(await readSshActionFile(path.join(service.root, 'advertisement.json'))).toBe(before) + expect((await send('projects.ensureBoard', { projectId: 'p1' })).ok).toBe(true) + }) + it('permits only one simultaneous startup and cannot overwrite its live owner', async () => { + await service.stop() + const first = new SshActionsService(root, store), second = new SshActionsService(root, store) + const attempts = await Promise.allSettled([first.start(), second.start()]) + expect(attempts.filter((r) => r.status === 'fulfilled')).toHaveLength(1) + service = attempts[0].status === 'fulfilled' ? first : second + const loser = service === first ? second : first + const before = await readSshActionFile(path.join(service.root, 'advertisement.json')) + await loser.stop() + expect(await readSshActionFile(path.join(service.root, 'advertisement.json'))).toBe(before) + expect((await send('projects.ensureBoard', { projectId: 'p1' })).ok).toBe(true) + }) + it('keeps its exclusive lifetime claim during an accepted master write and releases it after cleanup', async () => { + await service.stop() + let release!: () => void, entered!: () => void + const blocked = new Promise((r) => { release = r }), sawWrite = new Promise((r) => { entered = r }) + let blocking = false, writes = 0 + const io: RemoteWorkspaceIO = { read: async () => ({ status: 'absent' }), write: async () => { writes++; if (blocking) { entered(); await blocked } return true } } + store = new WorkspaceStore(io) + const ssh = { server: { host: 'synthetic', user: 'synthetic' }, remoteCwd: '/synthetic' } as unknown as Project['ssh'] + await store.save(workspace([project({ ssh })])) + service = new SshActionsService(root, store); await service.start(); blocking = true + const nonce = randomUUID() + await fs.writeFile(path.join(service.directory, nonce + '.request'), JSON.stringify({ version: 1, instance: service.instance, nonce, issuedAt: Date.now(), method: 'projects.ensureBoard', params: { projectId: 'p1' } }), { mode: 0o600 }) + const operation = service.tick(); await sawWrite + const stopping = service.stop() + const successor = new SshActionsService(root, store) + await expect(successor.start()).rejects.toThrow(/already served/) + expect(nodeFs.existsSync(path.join(service.root, 'claim/owner.json'))).toBe(true) + release(); await stopping; await expect(operation).rejects.toThrow(/instance changed/) + expect(nodeFs.existsSync(path.join(service.root, 'advertisement.json'))).toBe(false) + expect(nodeFs.existsSync(path.join(service.root, 'claim'))).toBe(false) + await successor.start(); service = successor + expect(JSON.parse(await readSshActionFile(path.join(service.root, 'advertisement.json'))).instance).toBe(successor.instance) + expect(writes).toBe(2) + }) + it('refuses an unverifiable foreign namespace claim or unsafe partial startup claim', async () => { + await service.stop() + const claim = path.join(service.root, 'claim'); await fs.mkdir(claim, { mode: 0o700 }) + await fs.writeFile(path.join(claim, 'owner.json'), JSON.stringify({ version: 1, instance: randomUUID(), pid: 2147483647, namespace: 'foreign' }), { mode: 0o600 }) + await expect(new SshActionsService(root, store).start()).rejects.toThrow(/Unverifiable/) + await fs.unlink(path.join(claim, 'owner.json')) + await expect(new SshActionsService(root, store).start()).rejects.toThrow(/Unsafe/) + }) + it('publishes a bounded uncertain receipt for an oversized applied result and never repeats it', async () => { + const huge = Array.from({ length: 4000 }, (_, i) => ({ id: 'column-' + i, title: 'x'.repeat(60), color: '#fff' })) + await store.save(workspace([project({ cwd, kanban: { columns: huge, assignments: [] } })])) + const spy = vi.spyOn(store, 'ensureRemoteBoard') + const nonce = randomUUID() + const response = await send('projects.ensureBoard', { projectId: 'p1' }, nonce) + expect(response.ok).toBe(false); expect(response.uncertain).toBe(true) + expect(response.error).toMatch(/response too large/) + expect((await fs.stat(path.join(service.directory, nonce + '.response'))).size).toBeLessThan(1024) + expect(await send('projects.ensureBoard', { projectId: 'p1' }, nonce)).toEqual(response) + expect(spy).toHaveBeenCalledTimes(1) + }) + it('keeps an idle advertisement inode unchanged, then refreshes its live heartbeat at5s', async () => { + const file = path.join(service.root, 'advertisement.json') + const original = await fs.stat(file) + const advert = JSON.parse(await readSshActionFile(file)) + vi.spyOn(Date, 'now').mockReturnValue(advert.updatedAt + 1000) + await service.tick(); await service.tick() + expect((await fs.stat(file)).ino).toBe(original.ino) + expect(JSON.parse(await readSshActionFile(file)).updatedAt).toBe(advert.updatedAt) + vi.spyOn(Date, 'now').mockReturnValue(advert.updatedAt + 5001) + await service.tick() + const refreshed = JSON.parse(await readSshActionFile(file)) + expect(refreshed.updatedAt).toBe(advert.updatedAt + 5001) + expect(refreshed.instance).toBe(service.instance) + expect(refreshed.remoteProjects).toBe(true) + expect((await fs.stat(file)).ino).not.toBe(original.ino) + }) + it('consumes a completed request once while retaining its outcome for a same-nonce retry', async () => { + const nonce = randomUUID() + const first = await send('projects.ensureBoard', { projectId: 'p1' }, nonce) + await expect(fs.stat(path.join(service.directory, nonce + '.request'))).rejects.toMatchObject({ code: 'ENOENT' }) + const resultFile = path.join(service.directory, nonce + '.response') + const receipt = await fs.stat(resultFile) + await service.tick(); await service.tick() + expect((await fs.stat(resultFile)).ino).toBe(receipt.ino) + expect(await send('projects.ensureBoard', { projectId: 'p1' }, nonce)).toEqual(first) + }) + it('rejects foreign UID ownership facts even when modes and inode kind match', async () => { + const actual = await fs.stat(path.join(service.root, 'advertisement.json')) + expect(safeSshActionStat(actual, false, actual.uid)).toBe(true) + expect(safeSshActionStat(actual, false, actual.uid + 1)).toBe(false) + }) + it('refuses FIFOs and hard links without blocking or treating them as requests', async () => { + const fifo = path.join(service.directory, randomUUID() + '.request') + execFileSync('mkfifo', ['-m', '600', fifo]) + await expect(readSshActionFile(fifo)).rejects.toThrow(/Unsafe/) + const original = path.join(root, 'hardlink-original'); await fs.writeFile(original, '{}', { mode: 0o600 }) + const linked = path.join(service.directory, randomUUID() + '.request'); await fs.link(original, linked) + await expect(readSshActionFile(linked)).rejects.toThrow(/Unsafe/) + }, 3000) + it('retains an uncertain callback outcome and does not repeat a potentially delivered nudge', async () => { + await service.stop() + const actions = { wake: vi.fn(() => { throw new Error('lost after dispatch') }), refresh: () => true, rename: () => true } + service = new SshActionsService(root, store, actions); await service.start() + const nonce = randomUUID() + const first = await send('node.wake', { nodeId: 'term-one' }, nonce) + expect(first.ok).toBe(false); expect(first.uncertain).toBe(true) + expect(await send('node.wake', { nodeId: 'term-one' }, nonce)).toEqual(first) + expect(actions.wake).toHaveBeenCalledTimes(1) + }) + it('refuses future or changed immutable issuedAt without mutating the Board', async () => { + const nonce = randomUUID(); times.set(nonce, Date.now() + 31_000) + expect((await send('projects.ensureBoard', { projectId: 'p1' }, nonce)).ok).toBe(false) + expect((await read()).kanban).toBeUndefined() + const normal = randomUUID(); expect((await send('projects.ensureBoard', { projectId: 'p1' }, normal)).ok).toBe(true) + times.set(normal, times.get(normal)! + 1) + expect((await send('projects.ensureBoard', { projectId: 'p1' }, normal)).error).toMatch(/nonce.*different/) + }) + it('accepts normal0755 selected profiles but refuses writable0775 profiles', async () => { + await service.stop(); await fs.chmod(root, 0o755) + service = new SshActionsService(root, store); await service.start() + expect((await send('projects.ensureBoard', { projectId: 'p1' })).ok).toBe(true) + await service.stop(); await fs.chmod(root, 0o775) + await expect(new SshActionsService(root, store).start()).rejects.toThrow(/Unsafe.*profile/) + await fs.chmod(root, 0o700) + }) + it('expires completed outcomes without reexecuting a replay or requiring a desktop restart', async () => { + await service.stop() + const actions = { wake: vi.fn(() => true), refresh: vi.fn(() => true), rename: vi.fn(() => true) } + service = new SshActionsService(root, store, actions); await service.start() + const nonce = randomUUID() + expect((await send('node.wake', { nodeId: 'term-one' }, nonce)).ok).toBe(true) + const issuedAt = times.get(nonce)! + vi.spyOn(Date, 'now').mockReturnValue(issuedAt + SSH_ACTIONS_RETRY_MS + 1) + await service.tick() + await expect(fs.stat(path.join(service.directory, nonce + '.request'))).rejects.toMatchObject({ code: 'ENOENT' }) + const expired = await send('node.wake', { nodeId: 'term-one' }, nonce) + expect(expired.ok).toBe(false); expect(expired.uncertain).toBe(true) + expect(actions.wake).toHaveBeenCalledTimes(1) + expect((await send('node.wake', { nodeId: 'term-one' })).ok).toBe(true) + expect(actions.wake).toHaveBeenCalledTimes(2) + }) + it('creates, moves and labels through the actual save queue and announces the mounted Board', async () => { + const seeded = await send('projects.ensureBoard', { projectId: 'p1' }) + expect(seeded.ok).toBe(true); expect(seeded.result.columns).toHaveLength(3) + const columnId = seeded.result.columns[1].id + const moved = await send('projects.setCardColumn', { projectId: 'p1', nodeId: 'term-one', columnId }) + expect(moved.result).toEqual({ moved: true }) + const labeled = await send('projects.editCardLabels', { projectId: 'p1', nodeId: 'term-one', create: [{ name: 'Mobile', color: 'blue' }] }) + expect(labeled.ok).toBe(true); expect(labeled.result.edited).toBe(true) + expect(labeled.result.labels[0].name).toBe('Mobile') + const persisted = await read() + expect(persisted.kanban.assignments).toEqual([{ nodeId: 'term-one', columnId }]) + expect(persisted.kanban.meta[0].labels).toEqual(labeled.result.cardLabelIds) + expect(fake.sent.filter((m) => m.channel === 'workspace:server-change')).toHaveLength(3) + const loaded = await store.load({ sideline: false }); await store.save(loaded) + expect((await read()).kanban).toEqual(persisted.kanban) + }) + it('retains the original outcome after a lost reply, and refuses nonce reuse with another payload', async () => { + const nonce = randomUUID() + const first = await send('projects.ensureBoard', { projectId: 'p1' }, nonce) + const before = await read() + await fs.unlink(path.join(service.directory, nonce + '.response')) + const replay = await send('projects.ensureBoard', { projectId: 'p1' }, nonce) + expect(replay).toEqual(first); expect(await read()).toEqual(before) + const changed = await send('projects.ensureBoard', { projectId: 'different' }, nonce) + expect(changed.ok).toBe(false); expect(changed.error).toMatch(/nonce.*different/) + expect(await read()).toEqual(before) + }) + it('dispatches a node nudge once even if its response is lost; reports delivery only', async () => { + await service.stop() + const actions = { wake: vi.fn(() => true), refresh: vi.fn(() => false), rename: vi.fn(() => true) } + service = new SshActionsService(root, store, actions); await service.start() + const nonce = randomUUID() + expect((await send('node.wake', { nodeId: 'term-one' }, nonce)).result).toEqual({ delivered: true }) + await fs.unlink(path.join(service.directory, nonce + '.response')) + expect((await send('node.wake', { nodeId: 'term-one' }, nonce)).ok).toBe(true) + expect(actions.wake).toHaveBeenCalledTimes(1) + expect((await send('node.refresh', { nodeId: 'term-one' })).ok).toBe(false) + expect((await send('node.rename', { nodeId: 'term-one', title: ' Renamed ' })).ok).toBe(true) + expect(actions.rename).toHaveBeenCalledWith('term-one', 'Renamed') + expect((await send('node.rename', { nodeId: 'term-one', title: '\u001bcommand' })).ok).toBe(false) + expect(actions.rename).toHaveBeenCalledTimes(1) + }) + it('refuses stale project, foreign node, invalid label/title and unsupported managed New without writes', async () => { + const before = await read() + for (const [method, params] of [ + ['projects.ensureBoard', { projectId: 'foreign-profile' }], + ['projects.setCardColumn', { projectId: 'p1', nodeId: 'foreign-node', columnId: null }], + ['projects.editCardLabels', { projectId: 'p1', nodeId: 'term-one', create: [{ name: 'x', color: 'bogus' }] }], + ['projects.registerNode', { projectId: 'p1', node: { id: 'invented' } }], + ['node.wake', { nodeId: 'term-one' }] + ] as Array<[string, Record]>) expect((await send(method, params)).ok).toBe(false) + expect(await read()).toEqual(before) + expect(service.methods).not.toContain('projects.registerNode') + }) + it('refuses a node ambiguously present in two persisted projects', async () => { + await store.save(workspace([project({ cwd }), project({ id: 'p2' })])) + expect((await send('projects.setCardColumn', { projectId: 'p1', nodeId: 'term-one', columnId: null })).ok).toBe(false) + }) + it('routes SSH project Board changes through the existing master mirror, never a local path', async () => { + await service.stop() + let remote: string | null = null + const writes: string[] = [] + const io: RemoteWorkspaceIO = { read: async () => remote ? { status: 'ok', content: remote } : { status: 'absent' }, write: async (_id, _ssh, raw) => { writes.push(raw); remote = raw; return true } } + store = new WorkspaceStore(io) + const ssh = { server: { host: 'synthetic', user: 'synthetic' }, remoteCwd: '/synthetic' } as unknown as Project['ssh'] + await store.save(workspace([project({ ssh })])) + service = new SshActionsService(root, store); await service.start() + const before = writes.length + expect((await send('projects.ensureBoard', { projectId: 'p1' })).ok).toBe(true) + expect(writes).toHaveLength(before + 1); expect(JSON.parse(remote!).kanban.columns).toHaveLength(3) + expect(fake.sent.some((m) => m.channel === 'workspace:server-change')).toBe(true) + await service.stop(); service = new SshActionsService(root, store, undefined, false); await service.start() + const count = writes.length + expect((await send('projects.ensureBoard', { projectId: 'p1' })).ok).toBe(false) + expect(writes).toHaveLength(count) + }) + it('does not serve a previous instance or another selected profile', async () => { + expect((await send('projects.ensureBoard', { projectId: 'p1' }, randomUUID(), randomUUID())).ok).toBe(false) + const before = await read() + const otherRoot = path.join(root, 'other'); await fs.mkdir(otherRoot, { mode: 0o700 }) + const other = new SshActionsService(otherRoot, new WorkspaceStore()) + await other.start() + try { + const nonce = randomUUID() + await fs.writeFile(path.join(other.directory, nonce + '.request'), JSON.stringify({ version: 1, instance: service.instance, nonce, issuedAt: Date.now(), method: 'projects.ensureBoard', params: { projectId: 'p1' } }), { mode: 0o600 }) + await other.tick() + expect(JSON.parse(await readSshActionFile(path.join(other.directory, nonce + '.response'))).ok).toBe(false) + } finally { await other.stop() } + expect(await read()).toEqual(before) + }) + it('refuses an unsafe request inode, size and permissions before dispatch', async () => { + const nonce = randomUUID(); const request = path.join(service.directory, nonce + '.request') + const outside = path.join(root, 'outside'); await fs.writeFile(outside, JSON.stringify({ version: 1, instance: service.instance, nonce, issuedAt: Date.now(), method: 'projects.ensureBoard', params: { projectId: 'p1' } }), { mode: 0o600 }) + await fs.symlink(outside, request); await service.tick() + expect(JSON.parse(await readSshActionFile(path.join(service.directory, nonce + '.response'))).ok).toBe(false) + await fs.unlink(request); await fs.writeFile(request, JSON.stringify({ version: 1, instance: service.instance, nonce, issuedAt: Date.now(), method: 'projects.ensureBoard', params: { projectId: 'p1', padding: 'x'.repeat(SSH_ACTIONS_MAX_BYTES) } }), { mode: 0o600 }) + await service.tick(); expect((await read()).kanban).toBeUndefined() + await fs.writeFile(request, await fs.readFile(outside)); await fs.chmod(request, 0o644) + await service.tick(); expect((await read()).kanban).toBeUndefined() + }) + it('refuses a symlink or shared directory and an already live profile owner', async () => { + await expect(new SshActionsService(root, store).start()).rejects.toThrow(/already served/) + await service.stop() + await fs.rm(service.root, { force: true, recursive: true }) + const elsewhere = path.join(root, 'elsewhere'); await fs.mkdir(elsewhere, { mode: 0o700 }) + await fs.symlink(elsewhere, service.root) + await expect(new SshActionsService(root, store).start()).rejects.toThrow(/Unsafe/) + await fs.unlink(service.root); await fs.mkdir(service.root, { mode: 0o755 }) + // Set the unsafe fixture's actual mode even when the runner inherits umask 0077. + await fs.chmod(service.root, 0o755) + await expect(new SshActionsService(root, store).start()).rejects.toThrow(/Unsafe/) + }) + it('fences a queued local write after stop, without publishing a success or changing the file', async () => { + let release!: () => void + let entered!: () => void + const blocked = new Promise((r) => { release = r }) + const sawRead = new Promise((r) => { entered = r }) + const writeSpy = vi.spyOn(fs, 'writeFile') + const original = fs.readFile.bind(fs) + vi.spyOn(fs, 'readFile').mockImplementation(async (...args: any[]) => { + if (args[0] === path.join(cwd, '.nodeterm/project.json')) { entered(); await blocked } + return original(...args as Parameters) + }) + const nonce = randomUUID() + await fs.writeFile(path.join(service.directory, nonce + '.request'), JSON.stringify({ version: 1, instance: service.instance, nonce, issuedAt: Date.now(), method: 'projects.ensureBoard', params: { projectId: 'p1' } }), { mode: 0o600 }) + const tick = service.tick() + await sawRead; const stopping = service.stop(); release() + await stopping + await expect(tick).rejects.toThrow(/instance changed/) + const staleTempWrites = writeSpy.mock.calls.filter(([file]) => String(file).includes('/.nodeterm/project.json.')) + expect(staleTempWrites).toHaveLength(0) + vi.restoreAllMocks() + expect((await read()).kanban).toBeUndefined() + await expect(fs.stat(path.join(service.directory, nonce + '.response'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('preserves0644 shared project permissions while control files remain0600', async () => { + const file = path.join(cwd, '.nodeterm/project.json') + await fs.chmod(file, 0o644) + expect((await send('projects.ensureBoard', { projectId: 'p1' })).ok).toBe(true) + expect((await fs.stat(file)).mode & 0o777).toBe(0o644) + expect((await fs.stat(path.join(service.root, 'advertisement.json'))).mode & 0o777).toBe(0o600) + }) + it('rechecks the instance after writing its temp and before publishing the shared project', async () => { + let release!: () => void + let entered!: () => void + const blocked = new Promise((r) => { release = r }) + const sawTemp = new Promise((r) => { entered = r }) + const original = fs.writeFile.bind(fs) + vi.spyOn(fs, 'writeFile').mockImplementation(async (...args: any[]) => { + const result = await original(...args as Parameters) + if (String(args[0]).includes('/.nodeterm/project.json.') && String(args[1]).includes('"kanban"')) { entered(); await blocked } + return result + }) + const nonce = randomUUID() + await fs.writeFile(path.join(service.directory, nonce + '.request'), JSON.stringify({ version: 1, instance: service.instance, nonce, issuedAt: Date.now(), method: 'projects.ensureBoard', params: { projectId: 'p1' } }), { mode: 0o600 }) + const tick = service.tick() + await sawTemp; const stopping = service.stop(); release() + await stopping + await expect(tick).rejects.toThrow(/instance changed/) + vi.restoreAllMocks() + expect((await read()).kanban).toBeUndefined() + const litter = (await fs.readdir(path.join(cwd, '.nodeterm'))).filter((n) => n.endsWith('.tmp')) + expect(litter).toEqual([]) + }) + it('rechecks the node in the file at the queued commit, instead of assigning an orphan card', async () => { + const board = await send('projects.ensureBoard', { projectId: 'p1' }) + const columnId = board.result.columns[1].id + const current = await read(); current.nodes = [] + await fs.writeFile(path.join(cwd, '.nodeterm/project.json'), JSON.stringify(current)) + expect((await send('projects.setCardColumn', { projectId: 'p1', nodeId: 'term-one', columnId })).result).toEqual({ moved: false }) + expect((await read()).kanban).toEqual(current.kanban) + }) +}) diff --git a/src/core/ssh-actions.ts b/src/core/ssh-actions.ts new file mode 100644 index 0000000000..6f1666850c --- /dev/null +++ b/src/core/ssh-actions.ts @@ -0,0 +1,344 @@ +/** Local SSH companion control. No hook bearer: only the logged-in OS user's private files. + * Board writes use WorkspaceStore's save queue; agents' hook endpoints never serve this API. */ +import nodeFs, { promises as fs, constants } from 'node:fs' +import path from 'node:path' +import { createHash, randomUUID } from 'node:crypto' +import { WorkspaceStore, type WorkspaceWriteFence } from './workspace-store' +import { renameAtomicSync } from './fs-atomic' +import { parseCardLabelEdit } from './project-kanban-write' +import type { ManagedTerminals } from './managed-terminals' + +export const SSH_ACTIONS_VERSION = 1 +export const SSH_ACTIONS_MAX_BYTES = 128 * 1024 +export const SSH_ACTIONS_METHODS = ['projects.ensureBoard', 'projects.setCardColumn', 'projects.editCardLabels'] as const +export const SSH_MANAGED_METHOD = 'sessions.createManagedV1' +export const SSH_NODE_METHODS = ['node.wake', 'node.refresh', 'node.rename'] as const +export const SSH_ACTIONS_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +export const SSH_ACTIONS_RETRY_MS = 10 * 60_000 +const MAX_LEDGER = 512 +const MAX_FILES = 1024 +// C0, DEL and C1 must not enter node titles, paths or ids through this surface. +// eslint-disable-next-line no-control-regex +const CONTROL = /[\u0000-\u001f\u007f-\u009f]/ +const id = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 200 && !CONTROL.test(v) +export interface SshActionsAdvertisement { version: 1; instance: string; pid: number; updatedAt: number; methods: string[]; remoteProjects: boolean } +export interface SshActionRequest { version: 1; instance: string; nonce: string; issuedAt: number; method: string; params: Record } +export interface SshActionResponse { version: 1; instance: string; nonce: string; ok: boolean; result?: unknown; error?: string; uncertain?: boolean; recoveryNodeId?: string } +export interface SshNodeActions { + wake(nodeId: string): boolean | Promise + refresh(nodeId: string): boolean | Promise + rename(nodeId: string, title: string): boolean | Promise +} + +const uid = (): number => process.getuid!() +export const safeSshActionStat = (s: Pick, directory: boolean, expectedUid = uid()): boolean => + (directory ? s.isDirectory() : s.isFile()) && s.uid === expectedUid && (s.mode & 0o077) === 0 +async function privateDir(dir: string, create = false): Promise { + if (create) await fs.mkdir(dir, { mode: 0o700 }).catch((e) => { if (e.code !== 'EEXIST') throw e }) + const s = await fs.lstat(dir) + if (!safeSshActionStat(s, true) || s.isSymbolicLink()) throw new Error('Unsafe SSH actions directory') +} +/** O_NONBLOCK also refuses FIFOs without waiting for another writer; no private-key reads. */ +export async function readSshActionFile(file: string): Promise { + const h = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) + try { + const s = await h.stat() + if (!safeSshActionStat(s, false) || s.size > SSH_ACTIONS_MAX_BYTES || s.nlink !== 1) throw new Error('Unsafe SSH actions file') + const bytes = Buffer.alloc(s.size + 1) + const { bytesRead } = await h.read(bytes, 0, bytes.length, 0) + if (bytesRead !== s.size) throw new Error('SSH actions file changed') + return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, bytesRead)) + } finally { await h.close() } +} +async function publish(file: string, value: unknown, current: () => boolean): Promise { + const raw = JSON.stringify(value) + if (Buffer.byteLength(raw) > SSH_ACTIONS_MAX_BYTES) throw new Error('SSH actions response too large') + const tmp = `${file}.${randomUUID()}.tmp` + try { + await fs.writeFile(tmp, raw, { flag: 'wx', mode: 0o600 }) + if (!current()) throw new Error('SSH actions instance changed') + // Synchronous commit: stop cannot interleave between the final fence and the atomic publish. + renameAtomicSync(tmp, file) + } finally { await fs.unlink(tmp).catch(() => {}) } +} +function canonical(value: unknown): string { + if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']' + if (value && typeof value === 'object') return '{' + Object.keys(value).sort().map((k) => JSON.stringify(k) + ':' + canonical((value as Record)[k])).join(',') + '}' + return JSON.stringify(value) +} +function request(raw: string, instance: string, nonce: string): SshActionRequest { + const r = JSON.parse(raw) as SshActionRequest + if (!r || r.version !== 1 || r.instance !== instance || r.nonce !== nonce || !SSH_ACTIONS_UUID.test(nonce) || typeof r.method !== 'string' || !Number.isSafeInteger(r.issuedAt) || r.issuedAt <= 0 || !r.params || typeof r.params !== 'object' || Array.isArray(r.params)) throw new Error('Invalid SSH actions request') + return r +} + +/** One bounded ledger per live instance. Outcomes remain within their immutable retry window; + * completed retries never execute twice, and a fresh process cannot adopt old uncertain requests. */ +export class SshActionsService { + readonly instance = randomUUID() + readonly root: string + readonly directory: string + private active = false + private retired = false + private startup?: Promise + private busy = false + private lastHeartbeat = 0 + private inFlight?: Promise + private stopPromise?: Promise + private timer?: ReturnType + private readonly ledger = new Map() + private readonly namespace = process.platform === 'linux' ? nodeFs.readlinkSync('/proc/self/ns/pid') : process.platform + readonly methods: string[] + constructor(readonly userData: string, private readonly store: WorkspaceStore, private readonly nodeActions?: SshNodeActions, private readonly allowRemoteProjects = true, private readonly managedTerminals?: ManagedTerminals) { + this.root = path.join(userData, 'ssh-actions') + this.directory = path.join(this.root, this.instance) + this.methods = [...SSH_ACTIONS_METHODS, ...(nodeActions ? SSH_NODE_METHODS : []), ...(managedTerminals?.supported() ? [SSH_MANAGED_METHOD] : [])] + } + private advertisement(): SshActionsAdvertisement { return { version: 1, instance: this.instance, pid: process.pid, updatedAt: Date.now(), methods: this.methods, remoteProjects: this.allowRemoteProjects } } + private syncFile(file: string): Record { + const fd = nodeFs.openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) + try { + const stat = nodeFs.fstatSync(fd) + if (!safeSshActionStat(stat, false) || stat.nlink !== 1 || stat.size > 4096) throw new Error('Unsafe SSH actions owner') + const raw = Buffer.alloc(stat.size + 1) + if (nodeFs.readSync(fd, raw, 0, raw.length, 0) !== stat.size) throw new Error('SSH actions owner changed') + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw.subarray(0, stat.size))) + } finally { nodeFs.closeSync(fd) } + } + private claimPath = (): string => path.join(this.root, 'claim') + private ownsClaim = (): boolean => { + try { + const dir = nodeFs.lstatSync(this.claimPath()) + if (!safeSshActionStat(dir, true)) return false + const owner = this.syncFile(path.join(this.claimPath(), 'owner.json')) + return owner.version === 1 && owner.instance === this.instance && owner.pid === process.pid && owner.namespace === this.namespace + } catch { return false } + } + private current = (): boolean => { + if (!this.active || !this.ownsClaim()) return false + try { + const advert = this.syncFile(path.join(this.root, 'advertisement.json')) + return advert.version === 1 && advert.instance === this.instance && advert.pid === process.pid + } catch { return false } + } + /** Every claimant/reclaimer uses this short exclusive transition gate. If a process dies inside + * the gate, fail closed for operator recovery rather than guessing that an unfinished owner died. + * A fully-started dead lifetime claim is reclaimable only in the same PID namespace. */ + private transition(): () => void { + const gate = path.join(this.root, 'transition') + try { nodeFs.mkdirSync(gate, { mode: 0o700 }) } + catch { throw new Error('SSH actions profile transition is already in progress') } + return () => nodeFs.rmdirSync(gate) + } + private removeAdvertisement(instance: string): void { + try { + const advert = this.syncFile(path.join(this.root, 'advertisement.json')) + if (advert.instance !== instance) throw new Error('SSH actions advertisement has another owner') + nodeFs.unlinkSync(path.join(this.root, 'advertisement.json')) + } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e } + } + private removeClaim(): void { + if (!this.ownsClaim()) return + nodeFs.unlinkSync(path.join(this.claimPath(), 'owner.json')) + nodeFs.rmdirSync(this.claimPath()) + } + private acquireClaim(): void { + try { + const dir = nodeFs.lstatSync(this.claimPath()) + if (!safeSshActionStat(dir, true) || nodeFs.readdirSync(this.claimPath()).join() !== 'owner.json') throw new Error('Unsafe SSH actions claim') + const owner = this.syncFile(path.join(this.claimPath(), 'owner.json')) + if (owner.version !== 1 || typeof owner.instance !== 'string' || !SSH_ACTIONS_UUID.test(owner.instance) || !Number.isSafeInteger(owner.pid) || (owner.pid as number) < 1 || owner.namespace !== this.namespace) throw new Error('Unverifiable SSH actions claim') + let dead = false + try { process.kill(owner.pid as number, 0) } + catch (e) { dead = (e as NodeJS.ErrnoException).code === 'ESRCH' } + if (!dead) throw new Error('SSH actions already served by this profile') + this.removeAdvertisement(owner.instance) + nodeFs.unlinkSync(path.join(this.claimPath(), 'owner.json')) + nodeFs.rmdirSync(this.claimPath()) + } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e } + // Handle an advertisement from a pre-claim version conservatively under the same transition gate. + try { + const old = this.syncFile(path.join(this.root, 'advertisement.json')) + if (typeof old.instance !== 'string' || !SSH_ACTIONS_UUID.test(old.instance) || !Number.isSafeInteger(old.pid) || (old.pid as number) < 1) throw new Error('Invalid SSH actions owner') + let dead = false + try { process.kill(old.pid as number, 0) } catch (e) { dead = (e as NodeJS.ErrnoException).code === 'ESRCH' } + if (!dead) throw new Error('SSH actions already served by this profile') + this.removeAdvertisement(old.instance) + } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e } + nodeFs.mkdirSync(this.claimPath(), { mode: 0o700 }) + nodeFs.writeFileSync(path.join(this.claimPath(), 'owner.json'), JSON.stringify({ version: 1, instance: this.instance, pid: process.pid, namespace: this.namespace }), { flag: 'wx', mode: 0o600 }) + } + start(): Promise { + if (this.startup || this.active || this.retired) return Promise.reject(new Error('SSH actions instance already started or stopped')) + const starting = this.startNow().finally(() => { this.startup = undefined }) + this.startup = starting + return starting + } + private async startNow(): Promise { + if (process.platform === 'win32' || !process.getuid) throw new Error('SSH actions need POSIX user ownership') + const profile = await fs.lstat(this.userData) + if (!profile.isDirectory() || profile.isSymbolicLink() || profile.uid !== uid() || (profile.mode & 0o022) !== 0) throw new Error('Unsafe SSH actions profile') + await privateDir(this.root, true) + if (this.retired) throw new Error('SSH actions instance stopped during startup') + const release = this.transition() + try { + this.acquireClaim() + await privateDir(this.directory, true) + if (this.retired) throw new Error('SSH actions instance stopped during startup') + this.active = true + await publish(path.join(this.root, 'advertisement.json'), this.advertisement(), () => !this.retired && this.active && this.ownsClaim()) + if (this.retired) throw new Error('SSH actions instance stopped during startup') + this.lastHeartbeat = Date.now() + this.timer = setInterval(() => void this.tick().catch(() => { /* leave pending outcome uncertain */ }), 250) + this.timer.unref() + } catch (error) { + this.active = false + if (this.ownsClaim()) { this.removeAdvertisement(this.instance); this.removeClaim() } + throw error + } finally { release() } + } + stop(): Promise { + if (this.stopPromise) return this.stopPromise + this.retired = true + this.active = false // no queued mutation or response may start/commit after this point + clearInterval(this.timer) + this.stopPromise = (async () => { + await this.startup?.catch(() => {}) + // A cache/master write may already have committed; keep the profile claimed until it settles. + await this.inFlight?.catch(() => {}) + if (!this.ownsClaim()) return + const release = this.transition() + try { this.removeAdvertisement(this.instance); this.removeClaim() } + finally { release() } + })() + return this.stopPromise + } + /** Exposed for deterministic filesystem interop and tests; production calls it on a timer. */ + tick(): Promise { + if (this.busy || !this.current()) return Promise.resolve() + this.busy = true + const operation = this.tickNow().finally(() => { this.busy = false; this.inFlight = undefined }) + this.inFlight = operation + return operation + } + private async tickNow(): Promise { + { + await privateDir(this.root) + await privateDir(this.directory) + if (Date.now() - this.lastHeartbeat >= 5000) { + await publish(path.join(this.root, 'advertisement.json'), this.advertisement(), this.current) + this.lastHeartbeat = Date.now() + } + for (const [nonce, saved] of this.ledger) { + if (Date.now() > saved.expiresAt) { + // Once expired, the immutable request timestamp refuses every later replay. + this.ledger.delete(nonce) + await fs.unlink(path.join(this.directory, nonce + '.request')).catch(() => {}) + await fs.unlink(path.join(this.directory, nonce + '.response')).catch(() => {}) + } + } + const entries = await fs.opendir(this.directory) + let count = 0 + try { + for await (const e of entries) { + if (++count > MAX_FILES) break + const nonce = e.name.endsWith('.request') ? e.name.slice(0, -8) : '' + if (!SSH_ACTIONS_UUID.test(nonce) || !this.current()) continue + await this.process(nonce) + } + } finally { await entries.close().catch(() => {}) } + } + } + private async process(nonce: string): Promise { + let response: SshActionResponse + try { + const r = request(await readSshActionFile(path.join(this.directory, nonce + '.request')), this.instance, nonce) + const fingerprint = createHash('sha256').update(canonical({ issuedAt: r.issuedAt, method: r.method, params: r.params })).digest('hex') + const saved = this.ledger.get(nonce) + if (r.issuedAt > Date.now() + 30_000) throw new Error('Invalid SSH actions request clock') + if (Date.now() > r.issuedAt + SSH_ACTIONS_RETRY_MS) { + response = { version: 1, instance: this.instance, nonce, ok: false, uncertain: true, error: 'SSH action retry window expired; check the computer before repeating it' } + } else if (saved) { + if (saved.fingerprint !== fingerprint) throw new Error('SSH actions nonce was reused with different input') + response = saved.response + } else { + if (this.ledger.size >= MAX_LEDGER) throw new Error('SSH actions are busy; wait for earlier requests to expire') + try { response = { version: 1, instance: this.instance, nonce, ok: true, result: await this.dispatch(r) } } + catch (e) { response = { version: 1, instance: this.instance, nonce, ok: false, error: (e as Error).message, ...((e as { uncertain?: boolean }).uncertain ? { uncertain: true } : {}), ...((e as { recoveryNodeId?: string }).recoveryNodeId ? { recoveryNodeId: (e as { recoveryNodeId: string }).recoveryNodeId } : {}) } } + if (Buffer.byteLength(JSON.stringify(response)) > SSH_ACTIONS_MAX_BYTES) { + response = { version: 1, instance: this.instance, nonce, ok: false, uncertain: true, error: 'SSH action response too large; check the computer before repeating it' } + } + this.ledger.set(nonce, { fingerprint, expiresAt: r.issuedAt + SSH_ACTIONS_RETRY_MS, response }) + } + } catch (e) { response = { version: 1, instance: this.instance, nonce, ok: false, error: (e as Error).message, ...((e as { uncertain?: boolean }).uncertain ? { uncertain: true } : {}), ...((e as { recoveryNodeId?: string }).recoveryNodeId ? { recoveryNodeId: (e as { recoveryNodeId: string }).recoveryNodeId } : {}) } } + await privateDir(this.directory) + await publish(path.join(this.directory, nonce + '.response'), response, this.current) + // Only a successfully read, regular owned request can be consumed. Invalid/symlink/FIFO + // files remain untouched; the same-UID owner can inspect/refuse them without us following them. + const input = path.join(this.directory, nonce + '.request') + await readSshActionFile(input).then(async () => { if (this.current()) await fs.unlink(input) }).catch(() => {}) + } + private project(projectId: unknown): string { + if (!id(projectId)) throw new Error('Invalid project id') + const meta = this.store.projectMetaFor(projectId) + if (!meta || (meta.ssh && !this.allowRemoteProjects)) throw new Error('This profile cannot serve the project') + return projectId + } + private node(nodeId: unknown, projectId?: string): string { + if (!id(nodeId)) throw new Error('Invalid node id') + const owners = this.store.persistedCanvases().filter((p) => p.nodes.some((n) => n.id === nodeId && n.kind === 'terminal')) + if (owners.length !== 1 || (projectId && owners[0].id !== projectId)) throw new Error('This profile does not uniquely own the session') + this.project(owners[0].id) + return nodeId + } + private async mutation(run: () => Promise | T): Promise { + try { return await run() } catch (e) { throw Object.assign(new Error((e as Error).message), { uncertain: true }) } + } + private async dispatch(r: SshActionRequest): Promise { + if (!this.current() || !this.methods.includes(r.method)) throw new Error('SSH action is unavailable on this instance') + const p = r.params + if (r.method === SSH_MANAGED_METHOD) return this.managedTerminals!.create(p, this.instance, this.current) + if (r.method.startsWith('projects.')) { + const projectId = this.project(p.projectId) + const fence: WorkspaceWriteFence = { current: this.current } + if (r.method === 'projects.ensureBoard') { + const columns = await this.mutation(() => this.store.ensureRemoteBoard(projectId, new Date(), fence)) + if (!columns) throw new Error('This project has no writable board') + return { columns } + } + const nodeId = this.node(p.nodeId, projectId) + fence.nodeId = nodeId + if (r.method === 'projects.setCardColumn') { + if (p.columnId !== null && !id(p.columnId)) throw new Error('Invalid column id') + return { moved: await this.mutation(() => this.store.setRemoteCardColumn(projectId, nodeId, p.columnId as string | null, new Date(), fence)) } + } + const edit = parseCardLabelEdit(p) + if (!edit) throw new Error('Invalid card label edit') + const result = await this.mutation(() => this.store.editRemoteCardLabels(projectId, nodeId, edit, new Date(), fence)) + if (!result) throw new Error('This project has no writable board') + return result + } + const nodeId = this.node(p.nodeId) + if (!this.current()) throw new Error('SSH actions instance changed') + let delivered: boolean + if (r.method === 'node.rename') { + if (typeof p.title !== 'string' || p.title.trim().length === 0 || p.title.length > 500 || CONTROL.test(p.title)) throw new Error('Invalid session title') + delivered = await this.mutation(() => this.nodeActions!.rename(nodeId, (p.title as string).trim())) + } else if (r.method === 'node.wake') delivered = await this.mutation(() => this.nodeActions!.wake(nodeId)) + else delivered = await this.mutation(() => this.nodeActions!.refresh(nodeId)) + if (!delivered) throw new Error('The desktop window is unavailable') + // Receipt proves the desktop nudge was delivered, not that a shell woke or repainted. + return { delivered: true } + } +} + +/** Unavailable on Windows/unsafe profiles/another owner: leave SSH capabilities unadvertised. */ +export async function startSshActionsService(userData: string, store: WorkspaceStore, nodeActions?: SshNodeActions, allowRemoteProjects = true, managedTerminals?: ManagedTerminals): Promise { + if (process.platform === 'win32') return undefined + let service: SshActionsService | undefined + try { + service = new SshActionsService(userData, store, nodeActions, allowRemoteProjects, managedTerminals) + await service.start() + return service + } catch { await service?.stop().catch(() => {}); return undefined } +} diff --git a/src/core/terminal-history.test.ts b/src/core/terminal-history.test.ts new file mode 100644 index 0000000000..8d4778f1ec --- /dev/null +++ b/src/core/terminal-history.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { HISTORY_MAX_BYTES, HISTORY_MAX_ROWS, searchTerminalHistory, validHistoryQuery } from './terminal-history' + +describe('retained terminal history search', () => { + it('finds old history, literal metacharacters, UTF-8, and significant spaces', () => { + const text = 'old.* Ω 😀\n' + 'recent\n'.repeat(500) + expect(searchTerminalHistory(text, '.* Ω 😀')).toEqual({ searchedLines: 501, truncated: false, rows: [{ line: 0, text: 'old.* Ω 😀' }] }) + expect(searchTerminalHistory(' Word\nword\n', ' Word').rows).toHaveLength(1) + expect(searchTerminalHistory('Word\n', 'word').rows).toEqual([]) + expect(searchTerminalHistory('unrelated Ω 😀\n', '.* Ω 😀').rows).toEqual([]) + expect(searchTerminalHistory('anything\n', '[unclosed').rows).toEqual([]) + }) + it('scans through capped results and reports a real truncated answer', () => { + const result = searchTerminalHistory('match\n'.repeat(HISTORY_MAX_ROWS + 100) + 'tail\n', 'match') + expect(result.rows).toHaveLength(HISTORY_MAX_ROWS) + expect(result.searchedLines).toBe(HISTORY_MAX_ROWS + 101) + expect(result.truncated).toBe(true) + expect(searchTerminalHistory('match\n'.repeat(HISTORY_MAX_ROWS), 'match').truncated).toBe(false) + }) + it('bounds UTF-8 answer bytes while continuing the full scan', () => { + const huge = '😀'.repeat(70_000) + 'needle' + expect(searchTerminalHistory(huge + '\nneedle\n', 'needle')).toEqual({ searchedLines: 2, truncated: true, rows: [{ line: 1, text: 'needle' }] }) + expect(() => searchTerminalHistory('x'.repeat(HISTORY_MAX_BYTES + 1), 'x')).toThrow(/50 MiB/) + }) + it('refuses invalid queries without trimming valid literals', () => { + for (const q of ['', 'x\ny', '\u0000', '\r', 'x'.repeat(257), undefined]) expect(validHistoryQuery(q)).toBe(false) + expect(validHistoryQuery(' ')).toBe(true) + expect(() => searchTerminalHistory('anything', '')).toThrow(/single-line/) + }) +}) diff --git a/src/core/terminal-history.ts b/src/core/terminal-history.ts new file mode 100644 index 0000000000..f0ea2c5a04 --- /dev/null +++ b/src/core/terminal-history.ts @@ -0,0 +1,33 @@ +/** Literal, case-sensitive search of ALL retained output, with a bounded answer. */ +export const HISTORY_MAX_BYTES = 50 * 1024 * 1024 +export const HISTORY_RESULT_BYTES = 256 * 1024 +export const HISTORY_MAX_ROWS = 200 +export const HISTORY_QUERY_MAX = 256 + +export interface HistoryRow { line: number; text: string } +export interface HistorySearch { searchedLines: number; truncated: boolean; rows: HistoryRow[] } + +export function validHistoryQuery(query: unknown): query is string { + // One literal line. No shell/regexp syntax is interpreted; spaces are significant. + // eslint-disable-next-line no-control-regex + return typeof query === 'string' && query.length > 0 && query.length <= HISTORY_QUERY_MAX && !/[\x00-\x1f\x7f]/.test(query) +} + +export function searchTerminalHistory(text: string, query: string): HistorySearch { + if (!validHistoryQuery(query)) throw new Error('Enter a single-line search of 1–256 characters.') + if (Buffer.byteLength(text, 'utf8') > HISTORY_MAX_BYTES) throw new Error('Retained history exceeds the 50 MiB search limit.') + const lines = text.split('\n') + if (lines.at(-1) === '') lines.pop() + const rows: HistoryRow[] = [] + let bytes = 0 + let truncated = false + for (let line = 0; line < lines.length; line++) { + const value = lines[line].replace(/\r$/, '') + if (!value.includes(query)) continue + const cost = Buffer.byteLength(value, 'utf8') + String(line).length + 2 + if (rows.length >= HISTORY_MAX_ROWS || bytes + cost > HISTORY_RESULT_BYTES) { truncated = true; continue } + bytes += cost + rows.push({ line, text: value }) + } + return { searchedLines: lines.length, truncated, rows } +} diff --git a/src/core/tmux-control-client.test.ts b/src/core/tmux-control-client.test.ts index 2443b88ca1..85614fe2de 100644 --- a/src/core/tmux-control-client.test.ts +++ b/src/core/tmux-control-client.test.ts @@ -55,7 +55,10 @@ class FakeControlSpawn implements ControlSpawn { } /** A started client plus its fake, wired with recording callbacks — the shape every test needs. */ -function makeClient(over: Partial<{ onOutput: (d: string) => void; onExit: () => void }> = {}) { +function makeClient( + over: Partial<{ onOutput: (d: string) => void; onExit: () => void }> = {}, + attachReply = true +) { const spawner = new FakeControlSpawn() const out: string[] = [] const exits: number[] = [] @@ -68,6 +71,7 @@ function makeClient(over: Partial<{ onOutput: (d: string) => void; onExit: () => spawner }) client.start() + if (attachReply) spawner.only.feed(reply(0)) return { client, spawner, out, exits, child: spawner.only } } @@ -78,6 +82,32 @@ function reply(num: number, body: string[] = [], ok = true): string { } describe('ControlModeClient.start', () => { + it('consumes the attach block before resolving a command queued immediately after start', async () => { + const { client, child } = makeClient({}, false) + let settled = false + const command = client.command('display-message -p pane').then((result) => { + settled = true + return result + }) + child.feed(reply(0)) // attach-session succeeded; this is not the stdin command's result + await Promise.resolve() + expect(settled).toBe(false) + child.feed(reply(1, ['%7 1'])) + await expect(command).resolves.toEqual({ ok: true, body: ['%7 1'] }) + }) + + it('rejects queued commands and retires the client when the attach reply fails', async () => { + const { client, child, exits } = makeClient({}, false) + const command = client.command('list-panes') + const rejected = expect(command).rejects.toThrow(/attach failed/) + child.feed(reply(0, ['no such session'], false)) + expect(client.alive).toBe(false) + expect(child.killed).toBe(1) + expect(exits).toEqual([1]) + await rejected + await expect(client.command('list-panes')).rejects.toThrow(/not running/) + }) + it('spawns tmux in control mode on the session socket', () => { const { spawner } = makeClient() expect(spawner.calls).toEqual([ diff --git a/src/core/tmux-control-client.ts b/src/core/tmux-control-client.ts index c517d714e3..51ac6f396b 100644 --- a/src/core/tmux-control-client.ts +++ b/src/core/tmux-control-client.ts @@ -85,6 +85,8 @@ export class ControlModeClient { private readonly pending: Pending[] = [] private proc: ReturnType | null = null private pendingBytes = 0 + /** The attach command in argv emits its own reply before any command typed on stdin. */ + private awaitingAttach = false private gone = false private disposed = false @@ -102,7 +104,15 @@ export class ControlModeClient { start(): void { if (this.proc || this.disposed) return const args = ['-L', this.opts.socket, '-C', 'attach-session', '-t', this.opts.sessionName] - this.proc = this.spawner.spawn(this.opts.tmuxBin, args) + // Reserve the initial block before a caller can queue a command immediately after start(). + // Otherwise that caller gets attach's empty success reply, and every later reply shifts too. + this.awaitingAttach = true + try { + this.proc = this.spawner.spawn(this.opts.tmuxBin, args) + } catch (error) { + this.awaitingAttach = false + throw error + } this.proc.stdout.on('data', (b) => this.onChunk(b)) this.proc.on('exit', () => this.die('control-mode client exited')) } @@ -187,6 +197,11 @@ export class ControlModeClient { const text = dec.write(Buffer.from(ev.data, 'latin1')) if (text) this.opts.onOutput(text) } else if (ev.kind === 'reply') { + if (this.awaitingAttach) { + this.awaitingAttach = false + if (!ev.ok) this.die('control-mode attach failed') + return + } this.pending.shift()?.resolve({ ok: ev.ok, body: ev.body }) } else if (ev.kind === 'exited') { this.die('tmux control-mode client exited') diff --git a/src/core/tmux-painter.test.ts b/src/core/tmux-painter.test.ts new file mode 100644 index 0000000000..d3de62780d --- /dev/null +++ b/src/core/tmux-painter.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { trackTmuxPainter, painterDetachArgs } from './tmux-painter' + +const session = 'nt-owned' +const actualNow = Date.now.bind(Date) +const birth = (pid: number): string => `linux:00000000-0000-0000-0000-000000000001:${pid}` +const client = (pid: number, ownSession = session): string => `${pid}|1700000000|${ownSession}|client-${pid}|999` +let dir: string +let rows: string[] +let calls: string[][] +let births: Map +const handles: Array> = [] +const run = vi.fn(async (args: string[]): Promise => { + calls.push(args) + if (args.includes('list-clients')) return rows.join('\n') + return '' +}) +function start(pid: number, extra: Partial[0]> = {}) { + births.set(pid, birth(pid)) + const handle = trackTmuxPainter({ + userData: dir, tmux: '/actual-tmux-boundary', socket: 'private-test', session, pid, + current: () => true, readBirth: async (p) => { + const value = births.get(p) + if (!value) throw Object.assign(new Error('process exited'), { code: 'ESRCH' }) + return value + }, run, ...extra + }) + handles.push(handle) + return handle +} +const detaches = (): string[][] => calls.filter((c) => c.includes('run-shell')) +const receiptDir = (): string => path.join(dir, 'tmux-painters', 'private-test', session) + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-painter-test-')) + rows = []; calls = []; births = new Map(); run.mockClear() + let clock = actualNow() + vi.spyOn(Date, 'now').mockImplementation(() => ++clock) +}) +afterEach(() => { + for (const h of handles.splice(0)) h.close() + vi.restoreAllMocks() + fs.rmSync(dir, { recursive: true, force: true }) +}) + +describe.skipIf(process.platform === 'win32')('private app painter ownership (A13)', () => { + it('leaves direct SSH, relay and unmarked app clients attached; receipts are private and retired', async () => { + rows = [client(1), client(2), client(3), client(10)] + const h = start(10) + await h.settled + expect(detaches()).toEqual([]) + const files = fs.readdirSync(receiptDir()) + expect(files).toHaveLength(1) + expect(fs.statSync(receiptDir()).mode & 0o777).toBe(0o700) + expect(fs.statSync(path.join(receiptDir(), files[0])).mode & 0o777).toBe(0o600) + h.close() + expect(fs.readdirSync(receiptDir())).toEqual([]) + }) + + it('replaces only the proven prior painter in the same profile, preserving other viewers', async () => { + vi.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValue(2000) + rows = [client(1), client(10)] + await start(10).settled + rows.push(client(20)) + await start(20).settled + expect(detaches()).toHaveLength(1) + const command = detaches()[0].at(-1) as string + expect(command).toContain('detach-client -t client-10') + expect(command).not.toContain('detach-client -t client-1,') + expect(command).toContain('#{==:#{client_pid},10}') + expect(command).toContain('#{==:#{client_pid},20}') + expect(command).toContain('#{==:#{client_created},1700000000}') + expect(command).toContain('#{==:#{client_session},nt-owned}') + expect(command).toContain('#{==:#{pid},999}') + }) + + it('preserves a PID whose process birth changed instead of trusting its stale receipt', async () => { + rows = [client(10)] + await start(10).settled + births.set(10, birth(9999)) + rows.push(client(20)) + await start(20).settled + expect(detaches()).toEqual([]) + expect(fs.readdirSync(receiptDir())).toHaveLength(1) // the reused PID's stale receipt was retired + }) + + it('reclaims a bounded batch of valid crash receipts before deciding takeover is overfull', async () => { + rows = [client(10)] + await start(10).settled + const source = JSON.parse(fs.readFileSync(path.join(receiptDir(), fs.readdirSync(receiptDir())[0]), 'utf8')) + for (let i = 1; i <= 40; i++) { + const id = `00000000-0000-4000-8000-${String(i).padStart(12, '0')}` + fs.writeFileSync(path.join(receiptDir(), `${id}.json`), JSON.stringify({ ...source, id, pid: 100 + i }), { mode: 0o600 }) + } + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1000) + rows.push(client(20)); await start(20).settled + expect(detaches()).toHaveLength(1) + expect(fs.readdirSync(receiptDir())).toHaveLength(2) + }) + + it('does not cross userData profiles or sessions', async () => { + rows = [client(10)] + await start(10).settled + const other = fs.mkdtempSync(path.join(dir, 'other-profile-')) + rows = [client(10, 'nt-neighbor'), client(20)] + await start(20, { userData: other }).settled + expect(detaches()).toEqual([]) + }) + + it('does not evict an old viewer while the new spawn has not attached', async () => { + rows = [client(10)] + await start(10).settled + const pending = start(20) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(detaches()).toEqual([]) + pending.close() + await pending.settled + expect(detaches()).toEqual([]) + }) + + it('cancels a late listing after the exact session generation was released', async () => { + rows = [client(10)] + await start(10).settled + let release!: (value: string) => void + const pending = start(20, { run: () => new Promise((resolve) => { release = resolve }) }) + await vi.waitFor(() => expect(release).toBeTypeOf('function')) + pending.close() + release([client(10), client(20)].join('\n')) + await pending.settled + expect(detaches()).toEqual([]) + expect(fs.readdirSync(receiptDir())).toHaveLength(1) + }) + + it('an earlier delayed attach retires itself beside a newer successful claim', async () => { + vi.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValue(2000) + rows = [] + const older = start(10) + await vi.waitFor(() => expect(fs.existsSync(receiptDir())).toBe(true)) + rows = [client(20)] + await start(20).settled + rows.push(client(10)) + await older.settled + expect(detaches()).toHaveLength(1) + expect(detaches()[0].at(-1)).toContain('detach-client -t client-10') + expect(detaches()[0].at(-1)).not.toContain('detach-client -t client-20,') + }) + + it('cannot publish a receipt after cancellation while process attestation was pending', async () => { + let finish!: (value: string) => void + const h = start(10, { readBirth: () => new Promise((resolve) => { finish = resolve }) }) + h.close(); finish(birth(10)); await h.settled + expect(fs.existsSync(path.join(dir, 'tmux-painters'))).toBe(false) + expect(calls).toEqual([]) + }) + + it('ignores public and symlinked receipt files', async () => { + rows = [client(10)] + await start(10).settled + const file = path.join(receiptDir(), fs.readdirSync(receiptDir())[0]) + fs.chmodSync(file, 0o644) + rows.push(client(20)); await start(20).settled + expect(detaches()).toEqual([]) + fs.chmodSync(file, 0o600) + const privateBytes = fs.readFileSync(file) + fs.unlinkSync(file); fs.symlinkSync(path.join(dir, 'external'), file) + fs.writeFileSync(path.join(dir, 'external'), privateBytes, { mode: 0o600 }) + rows.push(client(30)); await start(30).settled + expect(detaches().some((c) => c.at(-1)?.includes('detach-client -t client-10'))).toBe(false) + expect(fs.readFileSync(path.join(dir, 'external'))).toEqual(privateBytes) + }) + + it('does not treat oversized or differently scoped receipt data as authority', async () => { + rows = [client(10)] + await start(10).settled + const file = path.join(receiptDir(), fs.readdirSync(receiptDir())[0]) + const original = JSON.parse(fs.readFileSync(file, 'utf8')) + fs.writeFileSync(file, JSON.stringify({ ...original, extra: 'x'.repeat(3000) })) + rows.push(client(20)); await start(20).settled + expect(detaches()).toEqual([]) + fs.writeFileSync(file, JSON.stringify({ ...original, session: 'nt-neighbor' })) + rows = [client(10), client(30)] + await start(30).settled + expect(detaches()).toEqual([]) + }) + + it('rejects a symlinked ownership directory without following it', async () => { + const outside = fs.mkdtempSync(path.join(dir, 'outside-')) + fs.symlinkSync(outside, path.join(dir, 'tmux-painters')) + rows = [client(10)] + await start(10).settled + expect(fs.readdirSync(outside)).toEqual([]) + expect(calls).toEqual([]) + }) + + it('preserves every viewer when receipt bounds prevent complete ownership checking', async () => { + rows = [client(10)] + await start(10).settled + for (let i = 0; i < 32; i++) fs.writeFileSync(path.join(receiptDir(), `unknown-${i}`), '') + rows.push(client(20)); await start(20).settled + expect(detaches()).toEqual([]) + }) + + it('does not delete a replacement inode when the old receipt owner closes', async () => { + rows = [client(10)] + const h = start(10); await h.settled + const file = path.join(receiptDir(), fs.readdirSync(receiptDir())[0]) + fs.renameSync(file, `${file}.old`) + fs.writeFileSync(file, 'replacement', { mode: 0o600 }) + h.close() + expect(fs.existsSync(file)).toBe(true) + expect(fs.readFileSync(file, 'utf8')).toBe('replacement') + }) + + it('rechecks process birth immediately before retirement', async () => { + rows = [client(10)] + await start(10).settled + let reads = 0 + rows.push(client(20)) + await start(20, { readBirth: async (pid) => pid === 10 && ++reads > 1 ? birth(9999) : birth(pid) }).settled + expect(detaches()).toEqual([]) + }) + + it('does not evict the old painter after the incoming winning process changes', async () => { + rows = [client(10)] + await start(10).settled + let incomingReads = 0 + rows.push(client(20)) + await start(20, { readBirth: async (pid) => pid === 20 && ++incomingReads >= 4 ? birth(9999) : birth(pid) }).settled + expect(detaches()).toEqual([]) + }) + + it('preserves receipts on unknown process-attestation failures instead of claiming the process died', async () => { + rows = [client(10)] + await start(10).settled + const oldFile = path.join(receiptDir(), fs.readdirSync(receiptDir())[0]) + rows.push(client(20)) + await start(20, { readBirth: async (pid) => { + if (pid === 10) throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) + return birth(pid) + } }).settled + expect(detaches()).toEqual([]) + expect(fs.existsSync(oldFile)).toBe(true) + }) + + it('ignores unsafe client-name text rather than interpolating a tmux command', async () => { + rows = [client(10)] + await start(10).settled + rows = ['10|1700000000|nt-owned|client-10; kill-server|999', client(20)] + await start(20).settled + expect(detaches()).toEqual([]) + }) + + it('never uses broad detach or a process signal for the server-side ownership gate', () => { + const old = { pid: 10, created: '100', session, name: 'client-10', server: 999 } + const incoming = { pid: 20, created: '101', session, name: 'client-20', server: 999 } + const args = painterDetachArgs('private-test', old, incoming) + expect(args.slice(0, 4)).toEqual(['-L', 'private-test', 'run-shell', '-C']) + expect(args.at(-1)).toContain('#{==:#{client_name},client-10}') + expect(args.at(-1)).toContain('#{==:#{client_name},client-20}') + expect(args).not.toContain('-D') + expect(args).not.toContain('-a') + }) +}) diff --git a/src/core/tmux-painter.ts b/src/core/tmux-painter.ts new file mode 100644 index 0000000000..d0ad4d834d --- /dev/null +++ b/src/core/tmux-painter.ts @@ -0,0 +1,225 @@ +import fs from 'node:fs' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { performance } from 'node:perf_hooks' +import { readManagedProcessBirth } from './managed-pane' +import { isSessionName } from './tmux-naming' + +const CLIENT_FORMAT = '#{client_pid}|#{client_created}|#{session_name}|#{client_name}|#{pid}' +const MAX_RECEIPTS = 32 +const runFile = promisify(execFile) + +async function painterBirth(pid: number): Promise { + // Signal 0 is an existence check only. ESRCH proves that a stale receipt has no process; + // permission errors and later attestation failures do not authorize pruning. + process.kill(pid, 0) + if (process.platform === 'linux' && fs.statSync(`/proc/${pid}`).uid !== process.getuid?.()) + throw new Error('Not our tmux client process') + if (process.platform === 'darwin') { + const { stdout } = await runFile('ps', ['-o', 'uid=', '-p', String(pid)], { timeout: 1000, encoding: 'utf8' }) + if (stdout.trim() !== String(process.getuid?.())) throw new Error('Not our tmux client process') + } + return readManagedProcessBirth(pid) +} + +interface Client { + pid: number + created: string + session: string + name: string + server: number +} +interface Receipt { + version: 1 + pid: number + birth: string + session: string + socket: string + started: number + id: string +} +export interface PainterOptions { + userData: string + tmux: string + socket: string + session: string + pid: number + current(): boolean + /** OS/process boundary only; production always uses the actual child and tmux server. */ + readBirth?(pid: number): Promise + run?(args: string[]): Promise +} + +function clients(raw: string, session: string): Client[] { + const result: Client[] = [] + for (const line of raw.trim().split('\n')) { + const [pid, created, ownSession, name, server, ...extra] = line.split('|') + if (extra.length || !/^[1-9][0-9]*$/.test(pid) || !/^[1-9][0-9]*$/.test(created) || + !/^[1-9][0-9]*$/.test(server) || ownSession !== session || + !/^(?:\/[A-Za-z0-9_./-]+|client-[0-9]+)$/.test(name)) continue + const row = { pid: Number(pid), created, session, name, server: Number(server) } + if (Number.isSafeInteger(row.pid) && Number.isSafeInteger(row.server)) result.push(row) + } + return result +} + +function privateDirectory(dir: string): void { + try { fs.mkdirSync(dir, { mode: 0o700 }) } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } + const st = fs.lstatSync(dir) + if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid?.() || (st.mode & 0o777) !== 0o700) + throw new Error('Unowned tmux painter directory') +} + +function readReceipt(file: string, session: string, socket: string): Receipt | null { + let fd: number | undefined + try { + fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK) + const st = fs.fstatSync(fd) + if (!st.isFile() || st.uid !== process.getuid?.() || (st.mode & 0o777) !== 0o600 || st.size > 2048) return null + const r = JSON.parse(fs.readFileSync(fd, 'utf8')) as Receipt + return r.version === 1 && r.session === session && r.socket === socket && Number.isSafeInteger(r.pid) && r.pid > 0 && + typeof r.birth === 'string' && r.birth.length <= 128 && /^(?:linux|darwin):/.test(r.birth) && + Number.isSafeInteger(r.started) && r.started > 0 && /^[0-9a-f-]{36}$/.test(r.id) && + path.basename(file) === `${r.id}.json` ? r : null + } catch { return null } finally { if (fd !== undefined) fs.closeSync(fd) } +} + +function discardReceipt(file: string, receipt: Receipt): boolean { + try { + const st = fs.lstatSync(file) + const same = readReceipt(file, receipt.session, receipt.socket) + if (same?.id === receipt.id && same.pid === receipt.pid && same.birth === receipt.birth && + fs.lstatSync(file).ino === st.ino) { fs.unlinkSync(file); return true } + } catch { /* already gone or replaced */ } + return false +} + +function and(parts: string[]): string { + return parts.reduce((a, b) => `#{&&:${a},${b}}`) +} +function identity(c: Client): string { + return and([ + `#{==:#{client_pid},${c.pid}}`, `#{==:#{client_created},${c.created}}`, + `#{==:#{client_session},${c.session}}`, `#{==:#{client_name},${c.name}}`, + `#{==:#{pid},${c.server}}` + ]) +} + +/** Re-evaluate both client identities in tmux's command queue. No shell, PID signal or broad -D. + * Unsupported client-loop formats fail closed: they cannot name a client to detach. */ +export function painterDetachArgs(socket: string, old: Client, incoming: Client): string[] { + const incomingAlive = `#{L:#{?${identity(incoming)},1,}}` + const match = and([identity(old), incomingAlive]) + return ['-L', socket, 'run-shell', '-C', `#{L:#{?${match},detach-client -t ${old.name},}}`] +} + +/** + * Mark only a painter this process actually spawned. Attach first; a failed spawn/attach must not + * retire the previous app viewer. A later painter in the same private profile replaces only + * matching live PID/birth receipts. Phones, relay sinks, user tmux clients, other profiles and + * legacy unmarked app clients are preserved. Concurrent claims have one deterministic winner. + * The ordinary per-node create/join barrier still owns all viewers within a PtyManager. + */ +export function trackTmuxPainter(o: PainterOptions): { close(): void; settled: Promise } { + const started = Date.now() + const deadline = performance.now() + 2000 + let stopped = false + let file: string | undefined + let inode: number | undefined + let wait: ReturnType | undefined + let finishWait: (() => void) | undefined + const current = (): boolean => !stopped && o.current() + const close = (): void => { + stopped = true + if (wait) clearTimeout(wait) + finishWait?.() + if (file) { + try { + const st = fs.lstatSync(file) + if (!st.isSymbolicLink() && st.ino === inode) fs.unlinkSync(file) + } catch { /* already retired */ } + } + } + const delay = (): Promise => new Promise((resolve) => { + finishWait = resolve + wait = setTimeout(() => { wait = undefined; finishWait = undefined; resolve() }, 50) + wait.unref?.() + }) + const birth = o.readBirth ?? painterBirth + const run = o.run ?? (async (args: string[]): Promise => { + const { stdout } = await runFile(o.tmux, args, { timeout: 1000, maxBuffer: 64 * 1024, encoding: 'utf8' }) + return stdout + }) + const settled = (async (): Promise => { + try { + if (!current() || !isSessionName(o.session) || !/^[A-Za-z0-9_-]+$/.test(o.socket) || + !Number.isSafeInteger(o.pid) || o.pid < 1 || !process.getuid) return + const mine: Receipt = { version: 1, pid: o.pid, birth: await birth(o.pid), session: o.session, socket: o.socket, + started, id: randomUUID() } + if (!current()) return + const root = path.join(o.userData, 'tmux-painters') + privateDirectory(root) + const socketDir = path.join(root, o.socket) + privateDirectory(socketDir) + const dir = path.join(socketDir, o.session) + privateDirectory(dir) + file = path.join(dir, `${mine.id}.json`) + const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600) + try { fs.writeFileSync(fd, JSON.stringify(mine)); inode = fs.fstatSync(fd).ino } finally { fs.closeSync(fd) } + // node-pty returns before tmux has connected. Keep this bounded and cancelable on release. + for (let attempt = 0; attempt < 40 && current() && performance.now() < deadline; attempt++) { + let live: Client[] + try { live = clients(await run(['-L', o.socket, 'list-clients', '-t', `=${o.session}`, '-F', CLIENT_FORMAT]), o.session) } + catch { await delay(); continue } + if (!current()) return + const incoming = live.find((c) => c.pid === mine.pid) + if (!incoming) { await delay(); continue } + if (await birth(mine.pid) !== mine.birth || !current()) return + const names: string[] = [] + const directory = fs.opendirSync(dir) + try { + for (let entry = directory.readSync(); entry && names.length <= 128; entry = directory.readSync()) names.push(entry.name) + } finally { directory.closeSync() } + const overflow = names.length > 128 + const owned: Array<{ receipt: Receipt; client: Client }> = [] + let remaining = names.length + for (const name of names) { + if (!/^[0-9a-f-]{36}\.json$/.test(name)) continue + const receipt = readReceipt(path.join(dir, name), o.session, o.socket) + const client = receipt && live.find((c) => c.pid === receipt.pid) + if (!receipt) continue + if (performance.now() >= deadline) return + let sameBirth = false + let gone = false + try { sameBirth = await birth(receipt.pid) === receipt.birth; gone = !sameBirth } + catch (error) { gone = (error as NodeJS.ErrnoException).code === 'ESRCH' } + if (gone && discardReceipt(path.join(dir, name), receipt)) remaining-- + else if (sameBirth && client) owned.push({ receipt, client }) + if (!current()) return + } + // Crashes leave receipts, but never authority over a reused PID. Reclaim a bounded batch; + // incomplete/overfull directories preserve viewers until ownership can be fully checked. + if (overflow || remaining > MAX_RECEIPTS) return + const order = (a: Receipt, b: Receipt): number => a.started - b.started || a.id.localeCompare(b.id) + const winner = owned.reduce((a, b) => order(a.receipt, b.receipt) < 0 ? b : a, + { receipt: mine, client: incoming }) + // An earlier, slow attach cannot evict the newer claim or leave two app painters behind. + const retiring = winner.receipt.id === mine.id ? owned.filter((c) => c.receipt.id !== mine.id) : [{ receipt: mine, client: incoming }] + for (const old of retiring) { + if (performance.now() >= deadline) return + if (!current() || await birth(old.receipt.pid) !== old.receipt.birth || !current()) return + if (await birth(winner.receipt.pid) !== winner.receipt.birth || !current()) return + await run(painterDetachArgs(o.socket, old.client, winner.client)) + } + return + } + } catch { + // Attestation, private storage and tmux failures preserve other viewers. Never fall back to -D. + } + })() + return { close, settled } +} diff --git a/src/core/tmux-socket-isolation.guard.test.ts b/src/core/tmux-socket-isolation.guard.test.ts index ec709ac547..1274fafe7f 100644 --- a/src/core/tmux-socket-isolation.guard.test.ts +++ b/src/core/tmux-socket-isolation.guard.test.ts @@ -96,6 +96,10 @@ describe('the run cannot reach a live nodeterm tmux server', () => { * a file that deliberately names a live socket owes an explicit statement of where it binds. */ const REAL_SOCKET_ALLOWED = new Map([ + [ + 'src/core/managed-pane.realtmux.test.ts', + 'measures the canonical managed receipt and production paste bytes in its OWN private TMUX_TMPDIR; kills only its owned exact session' + ], [ 'src/core/agents/pane-owner.test.ts', 'the production bytes hardcode `-L nodeterm-rmt`; re-spelling it would judge different bytes' diff --git a/src/core/workspace-store.kanban.test.ts b/src/core/workspace-store.kanban.test.ts index 36a608f56d..06a9af91f8 100644 --- a/src/core/workspace-store.kanban.test.ts +++ b/src/core/workspace-store.kanban.test.ts @@ -30,8 +30,8 @@ const SSH = { server: { host: 'h', user: 'u' }, remoteCwd: '~/x' } as unknown as const projectFile = (): string => path.join(projRoot, '.nodeterm/project.json') const readFile = async (): Promise> => JSON.parse(await fs.readFile(projectFile(), 'utf-8')) -const externalChanges = (): Record[] => - fake.sent.filter((s) => s.channel === 'workspace:external-change').map((s) => s.args[0]) +const serverChanges = (): Record[] => + fake.sent.filter((s) => s.channel === 'workspace:server-change').map((s) => s.args[0]) /** An ssh IO whose "server" is one in-memory string, so a mirror write is observable. */ function fakeRemote(initial: string | null = null): RemoteWorkspaceIO & { content: string | null; writes: number } { @@ -83,8 +83,8 @@ describe('ensureRemoteBoard (the phone creating a board that never existed)', () expect(file.rev).toBe(2) // Ours, and announced by us — the renderer must adopt it or the next autosave reverts it. expect(store.isSelfWrite(projectFile(), await fs.readFile(projectFile(), 'utf-8'))).toBe(true) - expect(externalChanges()).toHaveLength(1) - expect(externalChanges()[0].kanban.columns).toHaveLength(3) + expect(serverChanges()).toHaveLength(1) + expect(serverChanges()[0].kanban.columns).toHaveLength(3) }) it('is idempotent: an existing board comes back untouched, with no second write', async () => { @@ -129,8 +129,8 @@ describe('setRemoteCardColumn (the phone moving a card)', () => { expect(await store.setRemoteCardColumn('p1', 'term-1', 'kcol-b')).toBe(true) expect((await readFile()).kanban.assignments).toEqual([{ nodeId: 'term-1', columnId: 'kcol-b', rank: expect.any(String) }]) - expect(externalChanges()).toHaveLength(1) - expect(externalChanges()[0].kanban.assignments).toEqual([{ nodeId: 'term-1', columnId: 'kcol-b', rank: expect.any(String) }]) + expect(serverChanges()).toHaveLength(1) + expect(serverChanges()[0].kanban.assignments).toEqual([{ nodeId: 'term-1', columnId: 'kcol-b', rank: expect.any(String) }]) }) it('null moves the card to Ungrouped', async () => { @@ -199,8 +199,8 @@ describe('an SSH project (the case the phone can never write itself)', () => { expect(io.writes).toBeGreaterThan(mirrorsBefore) expect(JSON.parse(io.content!).kanban.columns).toHaveLength(3) // Announced, so the live canvas adopts it instead of the next autosave writing the old board back. - expect(externalChanges()[0]).toMatchObject({ id: 'ssh1' }) - expect(externalChanges()[0].kanban.columns).toHaveLength(3) + expect(serverChanges()[0]).toMatchObject({ id: 'ssh1' }) + expect(serverChanges()[0].kanban.columns).toHaveLength(3) }) it('moves a card and mirrors it, and the reloaded workspace carries the move', async () => { @@ -248,7 +248,7 @@ describe('an SSH project (the case the phone can never write itself)', () => { expect(io.content).toBeNull() // nothing reached the server yet allowWrite = true - const adopted = externalChanges()[0] as Project + const adopted = serverChanges()[0] as Project await store.save(ws([adopted])) expect(JSON.parse(io.content!).kanban.columns).toHaveLength(3) }) @@ -288,7 +288,8 @@ describe('an SSH project (the case the phone can never write itself)', () => { expect(reads).toBe(0) expect(io.writes).toBe(0) - expect(externalChanges()).toEqual([]) + expect(serverChanges()).toEqual([]) + expect(fake.sent.filter((s) => s.channel === 'workspace:external-change')).toEqual([]) expect(await fs.readFile(path.join(userData, 'workspace.json'), 'utf-8')).toBe(indexBefore) }) }) @@ -321,8 +322,8 @@ describe('editRemoteCardLabels (the phone labelling a session)', () => { cardLabelIds: ['klbl-bug'] }) expect((await readFile()).kanban.meta).toEqual([{ nodeId: 'term-1', labels: ['klbl-bug'] }]) - expect(externalChanges()).toHaveLength(1) - expect(externalChanges()[0].kanban.meta).toEqual([{ nodeId: 'term-1', labels: ['klbl-bug'] }]) + expect(serverChanges()).toHaveLength(1) + expect(serverChanges()[0].kanban.meta).toEqual([{ nodeId: 'term-1', labels: ['klbl-bug'] }]) }) it('the first label on a board-less project seeds the default board', async () => { diff --git a/src/core/workspace-store.managed-terminals.test.ts b/src/core/workspace-store.managed-terminals.test.ts new file mode 100644 index 0000000000..12182e6b4e --- /dev/null +++ b/src/core/workspace-store.managed-terminals.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { initPlatform, resetPlatformForTests } from './platform' +import { fakePlatform } from './platform-fake' +import { WorkspaceStore } from './workspace-store' +import { IPC } from '../shared/ipc' +import type { CanvasNodeState, Project } from '../shared/types' + +let root: string, cwd: string, store: WorkspaceStore, platform: ReturnType, project: Project +const node = (): CanvasNodeState => ({ id: 'term-owned-abcdef', kind: 'terminal', title: 'Claude', color: '#fff', group: null, + position: { x: 100, y: 100 }, size: { width: 900, height: 560 }, cwd, shell: '/bin/bash', agentId: 'claude', accountId: 'personal', + pendingLaunch: { command: 'claude --session-id owned', after: [], attempted: true, manualOnly: true, executor: 'server' } }) +const file = () => path.join(cwd, '.nodeterm/project.json') +const read = async () => JSON.parse(await fs.readFile(file(), 'utf8')) +const indexFile = () => path.join(root, 'workspace.json') +const readIndex = async () => JSON.parse(await fs.readFile(indexFile(), 'utf8')) +const held = async () => (await readIndex()).entries.find((e: { id: string }) => e.id === 'p1').localExec[node().id]?.pendingLaunch +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'nt-managed-workspace-')); cwd = path.join(root, 'project'); await fs.mkdir(cwd) + platform = fakePlatform({ userDataDir: root }); initPlatform(platform); store = new WorkspaceStore() + project = { id: 'p1', name: 'owned', cwd, color: '#fff', viewport: { x: 0, y: 0, zoom: 1 }, nodes: [ + { ...node(), id: 'term-remote-donor', cwd: '/wrong-machine', ssh: { host: 'foreign', user: 'other' }, sshRemoteTmux: true } ] } + await store.save({ version: 2, activeProjectId: 'p1', projects: [project] }); platform.sent.length = 0 +}) +afterEach(async () => { vi.restoreAllMocks(); resetPlatformForTests(); await fs.rm(root, { force: true, recursive: true }) }) + +// This authenticated creation path and its private UID/fsync journal are POSIX-only. +describe.skipIf(process.platform === 'win32')('host-managed workspace publication', () => { + it('persists explicit local cwd/account/shell without copying a remote sibling and announces the mounted renderer', async () => { + const owned = node(); const doc = await read(); doc.future = { retained: true }; await fs.writeFile(file(), JSON.stringify(doc)); await fs.chmod(file(), 0o644) + expect(await store.appendManagedTerminal('p1', owned, { current: () => true })).toBe(true) + const appended = (await read()).nodes.find((n: CanvasNodeState) => n.id === owned.id) + expect(appended).toMatchObject({ cwd: '.', accountId: 'personal', agentId: 'claude' }) + expect(appended.shell).toBeUndefined(); + const index = JSON.parse(await fs.readFile(path.join(root, 'workspace.json'), 'utf8')) + expect(index.entries[0].localExec[owned.id].shell).toBe('/bin/bash') + expect(appended.ssh).toBeUndefined(); expect(appended.sshRemoteTmux).toBeUndefined() + expect((await read()).future).toEqual({ retained: true }); expect((await fs.stat(file())).mode & 0o777).toBe(0o644) + expect(platform.sent).toContainEqual({ to: 'broadcast', channel: IPC.workspaceServerChange, args: [expect.objectContaining({ id: 'p1', nodes: expect.arrayContaining([expect.objectContaining({ id: owned.id, cwd })]) })] }) + expect(platform.sent.some((event) => event.channel === IPC.workspaceExternalChange)).toBe(false) + }) + it('preserves guarded attempted launch until only the matching host submission completes', async () => { + const owned = node(); expect(await store.appendManagedTerminal('p1', owned, { current: () => true })).toBe(true) + expect((await read()).nodes.find((n: CanvasNodeState) => n.id === owned.id).pendingLaunch).toBeUndefined() + expect(await held()).toEqual(owned.pendingLaunch) + const sharedBefore = await fs.readFile(file(), 'utf8') + expect(await store.finishManagedTerminal('p1', owned, { current: () => true })).toBe(true) + expect(await held()).toBeUndefined() + expect((await readIndex()).entries[0].localExec[owned.id].shell).toBe('/bin/bash') + expect(await fs.readFile(file(), 'utf8')).toBe(sharedBefore) + expect(store.managedTerminalCurrent('p1', owned)).toBe(true) + }) + it.each(['cwd', 'shell', 'agentId', 'accountId', 'command', 'manualOnly', 'executor', 'ssh', 'agentSessionId', 'agentModel'])('does not clear a changed %s recovery node', async (key) => { + const owned = node(); await store.appendManagedTerminal('p1', owned, { current: () => true }) + const doc = await read(), saved = doc.nodes.find((n: CanvasNodeState) => n.id === owned.id) + if (['command', 'manualOnly', 'executor', 'shell'].includes(key)) { + const index = await readIndex(), local = index.entries[0].localExec[owned.id] + if (key === 'command') local.pendingLaunch.command = 'replacement' + else if (key === 'manualOnly') local.pendingLaunch.manualOnly = false + else if (key === 'executor') delete local.pendingLaunch.executor + else local.shell = '/replacement' + await fs.writeFile(indexFile(), JSON.stringify(index)); await store.load({ sideline: false }) + } else if (key === 'ssh') saved.ssh = { host: 'foreign', user: 'other' } + else saved[key] = 'replacement' + await fs.writeFile(file(), JSON.stringify(doc)) + expect(await store.finishManagedTerminal('p1', owned, { current: () => true })).toBe(false) + expect(await held()).toBeDefined() + }) + it('reopens managed recovery from the local index without trusting a shared launch', async () => { + const owned = node(); expect(await store.appendManagedTerminal('p1', owned, { current: () => true })).toBe(true) + const shared = await read() + shared.nodes.find((n: CanvasNodeState) => n.id === owned.id).pendingLaunch = { command: 'foreign command', after: [] } + await fs.writeFile(file(), JSON.stringify(shared)) + const reopened = new WorkspaceStore(); await reopened.load({ sideline: false }) + expect(reopened.managedTerminalCurrent('p1', owned, true)).toBe(true) + expect(await reopened.finishManagedTerminal('p1', owned, { current: () => true })).toBe(true) + expect(await held()).toBeUndefined() + expect(reopened.managedTerminalCurrent('p1', owned)).toBe(true) + expect((await read()).nodes.find((n: CanvasNodeState) => n.id === owned.id).pendingLaunch.command).toBe('foreign command') + }) + it('announces the fresh machine-local shell overlay when a load replaces the index during its publication', async () => { + const owned = node(), original = fs.writeFile.bind(fs), indexFile = path.join(root, 'workspace.json') + let replaced = false + vi.spyOn(fs, 'writeFile').mockImplementation(async (...args: any[]) => { + const result = await original(...args as Parameters) + if (!replaced && String(args[0]).startsWith(indexFile + '.') && String(args[0]).endsWith('.tmp')) { + replaced = true; await store.load({ sideline: false }) + } + return result + }) + expect(await store.appendManagedTerminal('p1', owned, { current: () => true })).toBe(true) + expect(replaced).toBe(true) + const announced = platform.sent.filter((event) => event.channel === IPC.workspaceServerChange).at(-1)!.args[0] as Project + expect(announced.nodes.find((n) => n.id === owned.id)).toMatchObject({ cwd, shell: '/bin/bash', agentId: 'claude', accountId: 'personal' }) + }) + it('recognizes canonical portable cwd and machine-local shell after an ordinary desktop save', async () => { + const owned = node(); await store.appendManagedTerminal('p1', owned, { current: () => true }) + expect(store.managedTerminalCurrent('p1', owned, true)).toBe(true) + const loaded = await store.load({ sideline: false }); await store.save(loaded) + expect((await read()).nodes.find((n: CanvasNodeState) => n.id === owned.id).shell).toBeUndefined() + expect(store.managedTerminalCurrent('p1', owned, true)).toBe(true) + expect(await store.finishManagedTerminal('p1', owned, { current: () => true })).toBe(true) + expect(await store.managedTerminalPresent('p1', owned, () => true)).toBe(true) + }) + it('rejects publication when the owning project target changes during the source read', async () => { + const original = fs.readFile.bind(fs) + vi.spyOn(fs, 'readFile').mockImplementation(async (...args: any[]) => { + const result = await original(...args as Parameters) + if (args[0] === file()) (store as any).index.entries[0].closed = true + return result + }) + expect(await store.appendManagedTerminal('p1', node(), { current: () => true })).toBe(false) + expect((await read()).nodes).toHaveLength(1) + }) + it('does not append duplicate ids or unsafe/remote node facts', async () => { + const owned = node(); expect(await store.appendManagedTerminal('p1', owned, { current: () => true })).toBe(true) + const before = await fs.readFile(file(), 'utf8') + expect(await store.appendManagedTerminal('p1', owned, { current: () => true })).toBe(false) + for (const unsafe of [{ ...owned, id: 'term-../target' }, { ...owned, id: 'term-new-a', sshRemoteTmux: true }, { ...owned, id: 'term-new-b', cwd: '' }]) { + expect(await store.appendManagedTerminal('p1', unsafe, { current: () => true })).toBe(false) + } + expect(await fs.readFile(file(), 'utf8')).toBe(before) + }) + it('refuses a cwd-less or SSH-owned project even when it carries a local-looking cwd', async () => { + const ssh = { server: { host: 'foreign', user: 'other' }, remoteCwd: '/remote' } as unknown as Project['ssh'] + await store.save({ version: 2, activeProjectId: 'p1', projects: [{ ...project, ssh, cwd }, { ...project, id: 'inline', cwd: undefined }] }) + expect(await store.appendManagedTerminal('p1', node(), { current: () => true })).toBe(false) + expect(await store.appendManagedTerminal('inline', node(), { current: () => true })).toBe(false) + await store.save({ version: 2, activeProjectId: 'p1', projects: [project] }) + // A malformed machine index can contain both: a local-looking cwd never grants a remote ref ownership. + ;(store as any).index.entries[0].ssh = ssh + const before = await fs.readFile(file(), 'utf8') + expect(await store.appendManagedTerminal('p1', node(), { current: () => true })).toBe(false) + expect(await fs.readFile(file(), 'utf8')).toBe(before) + }) + it('cancels a publication queued behind a save without staging its node', async () => { + let release!: () => void; const blocking = new Promise((r) => { release = r }) + const entered = vi.fn(); const original = fs.readFile.bind(fs) + vi.spyOn(fs, 'readFile').mockImplementation(async (...args: any[]) => { + if (args[0] === file() && !entered.mock.calls.length) { entered(); await blocking } + return original(...args as Parameters) + }) + const first = store.appendManagedTerminal('p1', { ...node(), id: 'term-first-abcd' }, { current: () => true }) + await vi.waitFor(() => expect(entered).toHaveBeenCalled()) + let current = true + const queued = store.appendManagedTerminal('p1', node(), { current: () => current }); current = false; release() + expect(await first).toBe(true); expect(await queued).toBe(false) + expect((await read()).nodes.map((n: CanvasNodeState) => n.id)).not.toContain(node().id) + }) + it('checks the instance again after the staged write and never publishes stale intent', async () => { + const original = fs.writeFile.bind(fs); let current = true + vi.spyOn(fs, 'writeFile').mockImplementation(async (...args: any[]) => { + const result = await original(...args as Parameters) + if (String(args[0]).startsWith(file() + '.') && String(args[0]).endsWith('.tmp')) current = false + return result + }) + expect(await store.appendManagedTerminal('p1', node(), { current: () => current })).toBe(false) + expect((await read()).nodes.map((n: CanvasNodeState) => n.id)).not.toContain(node().id) + expect((await fs.readdir(path.dirname(file()))).filter((n) => n.endsWith('.tmp'))).toHaveLength(0) + }) + it('does not overwrite a changed recovery command edited while completion was staged', async () => { + const owned = node(); await store.appendManagedTerminal('p1', owned, { current: () => true }) + const original = fs.writeFile.bind(fs) + vi.spyOn(fs, 'writeFile').mockImplementation(async (...args: any[]) => { + const result = await original(...args as Parameters) + if (String(args[0]).startsWith(indexFile() + '.') && String(args[0]).endsWith('.tmp')) { + const index = await readIndex(); index.entries[0].localExec[owned.id].pendingLaunch.command = 'changed by user' + await original(indexFile(), JSON.stringify(index)) + } + return result + }) + expect(await store.finishManagedTerminal('p1', owned, { current: () => true })).toBe(false) + expect((await held()).command).toBe('changed by user') + }) + it('preserves a newer local intent loaded after completion renamed the index', async () => { + const owned = node(); await store.appendManagedTerminal('p1', owned, { current: () => true }) + platform.sent.length = 0 + const replacement = { ...owned.pendingLaunch!, command: 'replacement after rename' } + const original = fs.unlink.bind(fs); let replaced = false + vi.spyOn(fs, 'unlink').mockImplementation(async (...args: any[]) => { + if (!replaced && String(args[0]).startsWith(indexFile() + '.') && String(args[0]).endsWith('.tmp')) { + replaced = true + const index = await readIndex(); index.entries[0].localExec[owned.id].pendingLaunch = replacement + await fs.writeFile(indexFile(), JSON.stringify(index)); await store.load({ sideline: false }) + } + return original(...args as Parameters) + }) + expect(await store.finishManagedTerminal('p1', owned, { current: () => true })).toBe(false) + expect(replaced).toBe(true) + expect(await held()).toEqual(replacement) + expect(store.managedTerminalCurrent('p1', { ...owned, pendingLaunch: replacement }, true)).toBe(true) + expect(platform.sent.filter((event) => event.channel === IPC.workspaceServerChange)).toHaveLength(0) + }) + it('preserves unrelated shared metadata changed while completion was staged', async () => { + const owned = node(); await store.appendManagedTerminal('p1', owned, { current: () => true }) + const original = fs.writeFile.bind(fs) + vi.spyOn(fs, 'writeFile').mockImplementation(async (...args: any[]) => { + const result = await original(...args as Parameters) + if (String(args[0]).startsWith(indexFile() + '.') && String(args[0]).endsWith('.tmp')) { + const doc = await read(); doc.future = { concurrentEdit: 'preserve this' }; await original(file(), JSON.stringify(doc)) + } + return result + }) + expect(await store.finishManagedTerminal('p1', owned, { current: () => true })).toBe(false) + expect((await read()).future).toEqual({ concurrentEdit: 'preserve this' }) + expect(await held()).toBeDefined() + }) + it('publishes completion in queue order without erasing a following ordinary save', async () => { + const owned = node(); await store.appendManagedTerminal('p1', owned, { current: () => true }) + const finishing = store.finishManagedTerminal('p1', owned, { current: () => true }) + const saving = store.save({ version: 2, activeProjectId: 'p1', projects: [{ ...project, nodes: [project.nodes[0], { ...owned, pendingLaunch: undefined, title: 'Renamed' }] }] }) + expect(await finishing).toBe(true); await saving + const final = (await read()).nodes.find((n: CanvasNodeState) => n.id === owned.id) + expect(final.title).toBe('Renamed'); expect(final.pendingLaunch).toBeUndefined() + }) +}) diff --git a/src/core/workspace-store.ts b/src/core/workspace-store.ts index 9c8f2080fb..fcf2c75adc 100644 --- a/src/core/workspace-store.ts +++ b/src/core/workspace-store.ts @@ -1,8 +1,8 @@ import type { SshConnection } from '../shared/ssh' -import { promises as fs } from 'fs' +import nodeFs, { promises as fs, constants } from 'fs' import { createHash, randomUUID } from 'node:crypto' import path from 'path' -import { renameAtomic, writeFileAtomic } from './fs-atomic' +import { renameAtomic, renameAtomicSync, writeFileAtomic } from './fs-atomic' import { IPC } from '../shared/ipc' import { platform } from './platform' import { @@ -13,7 +13,7 @@ import { import { contentOf, type CanvasContent } from '../shared/canvas-content' import { PROJECT_DIR, PROJECT_FILE, fileToProject, inlineProjectFileRelPath, isInlineProjectFileId, - projectToFile, resolveNodes, sameProjectContent, + projectToFile, resolveNodes, toPortableNodes, sameProjectContent, sanitizeLoadedClosedSessions, sanitizeNodeTriggers, serializeProjectFile, splitWorkspace, sanitizeHandedOffTo, sanitizeKanban, @@ -29,7 +29,7 @@ import { } from '../shared/project-settings' import { readProjectCapabilities, type ProjectCapability } from '../shared/project-capabilities' import type { CapabilityAckMap } from './project-capability-consent' -import { carryLocalNodeExec, hoistLegacyNodeExec, stripSharedNodeExec, type LocalNodeExecMap } from '../shared/node-exec' +import { carryLocalNodeExec, applyLocalNodeExec, hoistLegacyNodeExec, localNodeExec, stripSharedNodeExec, type LocalNodeExecMap } from '../shared/node-exec' import { collisionSeed, derivedProjectId, freshProjectId } from '../shared/project-id' import { pruneLayoutViewports, @@ -217,6 +217,26 @@ interface LoadedEntry { file?: ProjectFileV1 } +/** A file-service request may wait behind desktop saves; it must still own its instance at commit. */ +export interface WorkspaceWriteFence { current(): boolean; nodeId?: string } +const writableRequest = (raw: string, fence?: WorkspaceWriteFence): boolean => { + if (!fence) return true + if (!fence.current()) return false + if (!fence.nodeId) return true + try { return JSON.parse(raw).nodes?.filter((n: { id?: string; kind?: string }) => n.id === fence.nodeId && n.kind === 'terminal').length === 1 } + catch { return false } +} +async function writeFencedAtomic(file: string, raw: string, fence: WorkspaceWriteFence): Promise { + const tmp = `${file}.${process.pid}.${randomUUID()}.tmp` + try { + const mode = (await fs.stat(file)).mode & 0o777 + await fs.writeFile(tmp, raw, { flag: 'wx', mode }) + await fs.chmod(tmp, mode) // this shared project file keeps its original permission policy + if (!fence.current()) throw new Error('SSH actions instance changed') + renameAtomicSync(tmp, file) + } finally { await fs.unlink(tmp).catch(() => {}) } +} + export async function writeAtomic(filePath: string, content: string): Promise { // Unique temp per write: writers that bypass each other's queue (a second app instance, the SSH // poll's index write) must never share a tmp file — interleaved writes into one shared tmp @@ -2469,6 +2489,161 @@ export class WorkspaceStore { return true } + /** Host-owned creation persists the resolved local facts, never a sibling's SSH/cwd donor. + * Both intent and completion share the normal save queue and its final publication fence. */ + appendManagedTerminal(projectId: string, node: CanvasNodeState, fence: WorkspaceWriteFence): Promise { + return this.managedTerminalWrite(projectId, fence, (file) => { + if (!/^term-[a-z0-9]+-[a-z0-9]{1,16}$/.test(node.id) || node.kind !== 'terminal' || + typeof node.cwd !== 'string' || !node.cwd || node.ssh || node.sshRemoteTmux || + file.nodes.some((n) => n.id === node.id)) return false + file.nodes.push(stripSharedNodeExec(toPortableNodes([JSON.parse(JSON.stringify(node)) as CanvasNodeState], node.cwd))[0]) + return true + }, node, node) + } + + finishManagedTerminal(projectId: string, expectedNode: CanvasNodeState, fence: WorkspaceWriteFence): Promise { + return this.managedTerminalWrite(projectId, fence, (file) => { + const entry = this.index?.entries.find((e) => e.id === projectId) + const resolved = applyLocalNodeExec(resolveNodes(file.nodes, entry!.cwd!), entry!.localExec) + const matches = resolved.filter((n) => n.id === expectedNode.id && n.kind === 'terminal') + if (matches.length !== 1) return false + const node = matches[0], pending = node.pendingLaunch + if (pending?.command !== expectedNode.pendingLaunch?.command || + ['cwd', 'shell', 'agentId', 'accountId', 'agentSessionId', 'agentModel'].some((key) => node[key as keyof CanvasNodeState] !== expectedNode[key as keyof CanvasNodeState]) || + node.ssh || node.sshRemoteTmux || pending?.executor !== 'server' || + pending.attempted !== true || pending.manualOnly !== true) return false + // The matching intent is machine-local. managedTerminalWrite clears its localExec + // entry under the same final owner/source fence; it never publishes command text here. + return true + }, undefined, expectedNode) + } + + /** The final launch fence is synchronous: no saved-node edit may race an awaited pane probe. */ + managedTerminalCurrent(projectId: string, expectedNode: CanvasNodeState, pending = false): boolean { + const entries = this.index?.entries.filter((entry) => entry.id === projectId) ?? [] + const e = entries[0] + if (entries.length !== 1 || !e?.cwd || e.ssh || e.closed || e.cwd !== expectedNode.cwd) return false + let fd: number | undefined + try { + fd = nodeFs.openSync(projectFilePath(e.cwd), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) + const stat = nodeFs.fstatSync(fd) + if (!stat.isFile() || stat.size > 16 * 1024 * 1024) return false + const file = JSON.parse(nodeFs.readFileSync(fd, 'utf8')) as ProjectFileV1 + if (file.version !== 1 || !Array.isArray(file.nodes)) return false + const nodes = applyLocalNodeExec(resolveNodes(file.nodes, e.cwd), e.localExec) + const matches = nodes.filter((node) => node.id === expectedNode.id && node.kind === 'terminal') + const node = matches[0] + if (matches.length !== 1 || node.ssh || node.sshRemoteTmux || + !['cwd', 'shell', 'agentId', 'accountId', 'agentSessionId', 'agentModel'].every((key) => node[key as keyof CanvasNodeState] === expectedNode[key as keyof CanvasNodeState])) return false + if (!pending) return !node.pendingLaunch + const intent = node.pendingLaunch + return intent?.command === expectedNode.pendingLaunch?.command && intent?.executor === 'server' && + intent.attempted === true && intent.manualOnly === true && intent.after.length === 0 + } catch { return false } + finally { if (fd !== undefined) nodeFs.closeSync(fd) } + } + + /** Queue the observation behind ordinary saves; delivery additionally carries the sync fence. */ + managedTerminalPresent(projectId: string, expectedNode: CanvasNodeState, current: () => boolean, pending = false): Promise { + const run = this.saveChain.then(() => current() && this.managedTerminalCurrent(projectId, expectedNode, pending)) + this.saveChain = run.catch(() => {}) + return run + } + + private managedTerminalWrite(projectId: string, fence: WorkspaceWriteFence, change: (file: ProjectFileV1) => boolean, appendNode?: CanvasNodeState, expectedNode?: CanvasNodeState): Promise { + const run = this.saveChain.then(async () => { + if (!fence.current()) return false + const entries = this.index?.entries.filter((e) => e.id === projectId) ?? [] + const e = entries[0] + if (entries.length !== 1 || !e?.cwd || e.ssh || e.closed) return false + const cwd = e.cwd + const file = projectFilePath(cwd) + let source: string | undefined + const freshOwner = (): IndexEntryV3 | undefined => { + const fresh = this.index?.entries.filter((entry) => entry.id === projectId) ?? [] + if (!fence.current() || fresh.length !== 1 || fresh[0].cwd !== cwd || fresh[0].ssh || fresh[0].closed) return undefined + return fresh[0] + } + const owned = (): boolean => { + if (!freshOwner()) return false + if (source === undefined) return true + if (!appendNode && expectedNode && !this.managedTerminalCurrent(projectId, expectedNode, true)) return false + let fd: number | undefined + try { + fd = nodeFs.openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) + const stat = nodeFs.fstatSync(fd) + return stat.isFile() && stat.size <= 16 * 1024 * 1024 && nodeFs.readFileSync(fd, 'utf8') === source + } catch { return false } + finally { if (fd !== undefined) nodeFs.closeSync(fd) } + } + let parsed: ProjectFileV1 + try { + source = await fs.readFile(file, 'utf8') + parsed = JSON.parse(source) as ProjectFileV1 + if (!owned() || parsed.version !== 1 || !Number.isSafeInteger(parsed.rev) || !Array.isArray(parsed.nodes) || !change(parsed)) return false + if (appendNode) { + const exec = localNodeExec([appendNode]) + if (exec) { + const index = JSON.parse(JSON.stringify(this.index)) as WorkspaceIndexV3 + this.applySettingsToIndex(index) + const target = index.entries.find((entry) => entry.id === projectId)! + target.localExec = { ...target.localExec, ...exec } + await writeFencedAtomic(this.indexPath, JSON.stringify(index), { current: owned }) + if (!owned()) return false + const fresh = this.index!.entries.find((entry) => entry.id === projectId)! + fresh.localExec = target.localExec + } + } + if (!appendNode && expectedNode) { + // Completion changes only this machine's held launch, not the shared canvas. Read + // the actual index too: a newer local intent staged by another writer must survive. + const indexSource = await fs.readFile(this.indexPath, 'utf8') + const index = JSON.parse(indexSource) as WorkspaceIndexV3 + const targets = index.entries.filter((entry) => entry.id === projectId) + const target = targets[0] + const originalLocal = JSON.stringify(target?.localExec?.[expectedNode.id]) + if (targets.length !== 1 || target.cwd !== cwd || target.ssh || target.closed || + originalLocal !== JSON.stringify(freshOwner()?.localExec?.[expectedNode.id])) return false + const exec = { ...target.localExec, [expectedNode.id]: { ...target.localExec?.[expectedNode.id] } } + delete exec[expectedNode.id].pendingLaunch + target.localExec = exec + this.applySettingsToIndex(index) + const sameIndex = (raw: string): boolean => { + let fd: number | undefined + try { + fd = nodeFs.openSync(this.indexPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK) + const stat = nodeFs.fstatSync(fd) + return stat.isFile() && stat.size <= 16 * 1024 * 1024 && nodeFs.readFileSync(fd, 'utf8') === raw + } catch { return false } + finally { if (fd !== undefined) nodeFs.closeSync(fd) } + } + const indexRaw = JSON.stringify(index) + await writeFencedAtomic(this.indexPath, indexRaw, { current: () => owned() && sameIndex(indexSource) }) + const fresh = freshOwner() + const currentLocal = JSON.stringify(fresh?.localExec?.[expectedNode.id]) + // Temp cleanup yields after rename. A load during that turn may have adopted a + // replacement intent: never overwrite it in memory or announce our old completion. + if (!fresh || !sameIndex(indexRaw) || + (currentLocal !== originalLocal && currentLocal !== JSON.stringify(exec[expectedNode.id]))) return false + fresh.localExec = { ...fresh.localExec, [expectedNode.id]: exec[expectedNode.id] } + if (!this.managedTerminalCurrent(projectId, expectedNode, false)) return false + this.announceProjectFile(fresh, parsed) + return true + } + parsed.rev++; parsed.savedAt = new Date().toISOString() + const raw = JSON.stringify(parsed, null, 2) + await writeFencedAtomic(file, raw, { current: owned }) + const fresh = freshOwner() + if (!fresh || (expectedNode && !this.managedTerminalCurrent(projectId, expectedNode, !!appendNode?.pendingLaunch))) return false + this.lastWritten.set(file, raw); this.revs.set(fresh.id, parsed.rev) + this.announceProjectFile(fresh, parsed) + return true + } catch { return false } + }) + this.saveChain = run.catch(() => {}) + return run + } + /** * Takes a DESTROYED session's node off its project's canvas — the host side of the relay * `pty.destroy` verb ("End session" on the phone), run AFTER the tmux kill so the file only ever @@ -2549,9 +2724,9 @@ export class WorkspaceStore { * IDEMPOTENT, and that is the safety property: an existing board is returned untouched and * nothing is written, so the phone may ask on every tap. */ - ensureRemoteBoard(projectId: string, now = new Date()): Promise { + ensureRemoteBoard(projectId: string, now = new Date(), fence?: WorkspaceWriteFence): Promise { const run = this.saveChain.then(() => - this.kanbanWriteNow(projectId, (raw) => ensureProjectBoard(raw, now)) + this.kanbanWriteNow(projectId, (raw) => ensureProjectBoard(raw, now), fence) ) this.saveChain = run.catch(() => {}) // The board that is THERE NOW, whether this call seeded it or found it. `ensureProjectBoard` @@ -2581,10 +2756,11 @@ export class WorkspaceStore { projectId: string, nodeId: string, columnId: string | null, - now = new Date() + now = new Date(), + fence?: WorkspaceWriteFence ): Promise { const run = this.saveChain.then(() => - this.kanbanWriteNow(projectId, (raw) => setProjectCardColumn(raw, nodeId, columnId, now)) + this.kanbanWriteNow(projectId, (raw) => setProjectCardColumn(raw, nodeId, columnId, now), fence) ) this.saveChain = run.catch(() => {}) return run.then((res) => res?.written === true) @@ -2609,10 +2785,11 @@ export class WorkspaceStore { projectId: string, nodeId: string, edit: CardLabelEdit, - now = new Date() + now = new Date(), + fence?: WorkspaceWriteFence ): Promise<{ edited: boolean; labels: KanbanLabel[]; cardLabelIds: string[] } | null> { const run = this.saveChain.then(() => - this.kanbanWriteNow(projectId, (raw) => editProjectCardLabels(raw, nodeId, edit, now)) + this.kanbanWriteNow(projectId, (raw) => editProjectCardLabels(raw, nodeId, edit, now), fence) ) this.saveChain = run.catch(() => {}) return run.then((res) => { @@ -2640,7 +2817,7 @@ export class WorkspaceStore { * * - **local ref** (`cwd`): read the file, transform, write it atomically — exactly * `appendRemoteNodeNow`'s shape, including recording the write in `lastWritten` and announcing - * it on `workspaceExternalChange` rather than letting the watcher discover our own edit. + * it on `workspaceServerChange` rather than letting the watcher discover our own edit. * - **ssh ref** (`ssh` + `cache`): the file is on ANOTHER machine and only this desktop writes * it. So the write goes where the desktop's own board edits go — into `e.cache` — and is then * pushed by the ordinary mirror (`mirrorSshCache`, which re-reads and rescues the server's own @@ -2659,7 +2836,8 @@ export class WorkspaceStore { */ private async kanbanWriteNow( projectId: string, - transform: (raw: string) => string | null + transform: (raw: string) => string | null, + fence?: WorkspaceWriteFence ): Promise<{ file: ProjectFileV1; written: boolean } | null> { const e = this.index?.entries.find((x) => x.id === projectId) if (!e) return null @@ -2669,7 +2847,9 @@ export class WorkspaceStore { // BEFORE the transform, so the local cache never diverges and the renderer is never told // about a change that will not land. if (e.handedOffTo) return null - const updated = transform(serializeProjectFile(e.cache)) + const cacheRaw = serializeProjectFile(e.cache) + if (!writableRequest(cacheRaw, fence)) return null + const updated = transform(cacheRaw) if (updated === null) return { file: e.cache, written: false } let parsed: ProjectFileV1 try { @@ -2707,10 +2887,12 @@ export class WorkspaceStore { return null // unparsable: there is no board to report and none was written } } + if (!writableRequest(raw, fence)) return null const updated = transform(raw) if (updated === null) return current() try { - await writeAtomic(file, updated) + if (fence) await writeFencedAtomic(file, updated, fence) + else await writeAtomic(file, updated) } catch { return current() } @@ -2729,12 +2911,14 @@ export class WorkspaceStore { return { file: parsed, written: true } } - /** Tell the renderer about a project file THIS store just rewrote outside of `save()`. Shared by - * the kanban verbs; the same payload `appendRemoteNode`/`removeRemoteNode` build by hand. */ + /** Adopt this core's successful phone Board edit live, even while the canvas is dirty. + * The outside-file channel asks the user to resolve overlapping shared changes, including + * kanban, so it can leave a mounted Board stale behind its conflict strip. This is our own + * write: the existing server-change consumer keeps live canvas edits and adopts the Board. */ private announceProjectFile(e: IndexEntryV3, file: ProjectFileV1): void { try { platform().broadcast( - IPC.workspaceExternalChange, + IPC.workspaceServerChange, fileToProject(file, { id: e.id, cwd: e.cwd, diff --git a/src/main/index.ts b/src/main/index.ts index 3372a392a0..42c43e6cc9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -8,7 +8,7 @@ import { join, resolve, posix } from 'path' import { startSessionNameSweep, displayNodeTitle } from '../core/session-name-sweep' import { startTriggerService } from '../core/trigger-service' import { readAgentSessionName, type AgentSessionNameDeps } from '../core/agent-session-name' -import { readFile, realpath as fsRealpath, lstat as fsLstat, writeFile as fsWriteFile } from 'fs/promises' +import { realpath as fsRealpath, lstat as fsLstat, writeFile as fsWriteFile } from 'fs/promises' import { existsSync, statSync, openSync, fstatSync, readFileSync, closeSync } from 'fs' import { homedir, hostname } from 'os' import { randomUUID } from 'crypto' @@ -102,9 +102,13 @@ import { stationRecipient } from '../shared/station-notice' import type { RemoteLogExec } from '../core/board-log' import type { PtyCreateOptions, TranscriptPresence } from '../shared/types' import { boardLogRemotePath } from '../core/board-log' -import { PtyManager } from '../core/pty-manager' +import { PtyManager, resolveLocalSessionShell } from '../core/pty-manager' import { desktopHeadlessRequest, launchHeadless } from '../core/headless-launch' import { WorkspaceStore } from '../core/workspace-store' +import { startSshActionsService, type SshActionsService } from '../core/ssh-actions' +import { ManagedTerminals } from '../core/managed-terminals' +import { createManagedTerminalPlanner } from '../core/managed-terminal-plan' +import { findInLoginPath } from '../core/exec-path' import type { CardLabelEdit } from '../core/project-kanban-write' import { WorkspaceWatcher } from '../core/workspace-watcher' import { SettingsStore } from '../core/settings-store' @@ -175,10 +179,13 @@ import { projectCapabilityGrantedFor } from '../shared/project-capability-consen import { askpassServer, ensureAskpassScript } from './remote-ssh/ssh-askpass' import { appSshAgent } from './remote-ssh/ssh-agent' import { + answerPendingLocal, + answerPendingHookLocal, localHeldPermissionIo, startPendingSweep, isValidPendingId, - syntheticAnsweredEvent + syntheticAnsweredEvent, + type PendingAnswerResult } from '../core/agents/pending-approvals' import { answerHeldPermission, isStructuredTicket, type HeldPermissionIo } from '../core/agents/permission-decision' import type { AnswerPermissionPayload } from '../shared/agents/permission-answer' @@ -215,13 +222,16 @@ import { onMirrorFlush, flush as flushAgentStatusMirror, recordAgentEvent, + hookTicketStillOpen, recordQuestionResult, turnInterruptEvent, ignoreQuestionHook, ackDone, + mirrorOwnsNode, recordRawToolEvent, recordContextUsage, setMirrorSettingsProvider, + mirrorClaudeAccount, setMirrorLiveNodesProvider, setMirrorUsageProvider, buildMirrorUsage, @@ -240,6 +250,8 @@ import { mirrorEntry, pendingTicketsFor } from '../core/agent-status-mirror' +import { buildProjectsListBlob } from '../core/projects-list-blob' +import { createHostLanReporter } from './remote/host-lan-report' import { mirrorCustomAgents } from '../core/mirror-custom-agents' import { paneOwnerProject } from '../core/agents/pane-ownership' import { createPushNotify, createLiveUpdatePush } from '../core/push-notify' @@ -347,8 +359,8 @@ import { initClaudeAccounts } from './claude-accounts' import { initCodexAccounts } from './codex-accounts' import { claudeCliCaps, registerClaudeCliIpc, type ClaudeCliCaps } from '../core/claude-cli' import type { CodexCliCaps } from '../shared/types' -import { registerGrokCliIpc } from '../core/grok-cli' -import { refreshCodexIdentityCaps, registerCodexIdentityIpc } from '../core/codex-identity-caps' +import { grokCliCaps, registerGrokCliIpc } from '../core/grok-cli' +import { codexIdentityCaps, refreshCodexIdentityCaps, registerCodexIdentityIpc } from '../core/codex-identity-caps' import { codexCliCaps, registerCodexCliIpc } from '../core/codex-cli' import { registerWallpaperIpc } from '../core/wallpaper' import { registerRunConfigIpc, stopAllRunWatches } from '../core/run-service' @@ -388,8 +400,11 @@ import { API_BASE as RELAY_API_BASE, RELAY_URL } from './remote/host-service' -import { initStandingHost } from './remote/standing-host' +import { initStandingHost, rememberRevokedStandingPhone } from './remote/standing-host' +import { createHostNewSessions } from './remote/host-new-sessions' import { initRelayHost } from './remote/relay-host-service' +import { loadApprovedDevices, pinApprovedDeviceIf, updateApprovedDevices } from './remote/approved-devices' +import { pinDevice } from './remote/approved-devices-core' import { PIN_ROLES, phonePins, retireLegacyPinFile } from './remote/approved-devices' import { revokeAllPhones, revokePeerKey } from './remote/peer-revoke' import { publicKeyToB64, type KeyPair } from './remote/e2ee' @@ -719,34 +734,18 @@ ptyManager.setProjectSpawnOverrides( }) ) -// Markers delimiting the `projects.list` relay blob. The iOS client splits on these exact -// strings to recover [workspace.json | newline-joined tmux session names | agent-status.json], -// matching the SSH browse pipeline it already uses — keep them in sync with NodetermProjects.swift. -const NT_PROJECTS_MARK = '--NT-PROJECTS-SPLIT--' -const NT_STATUS_MARK = '--NT-STATUS-SPLIT--' - /** - * Build the marker-delimited projects blob served over the relay's `projects.list` RPC. Reads the - * same files the SSH browse path reads locally on the host (no SSH): `workspace.json` + - * `agent-status.json` under userData, plus the live nodeterm tmux session names. Every read is - * best-effort (missing files degrade to an empty section) so this never throws. + * The `projects.list` relay blob. Assembled by `buildProjectsListBlob` (src/core/projects-list-blob.ts), + * which the Android interop fixture calls too, so the markers, the section order and the read-only + * workspace load live in one place; this shell only names its sources: the workspace store, userData + * (where the agent-status mirror writes its file) and the live nodeterm tmux session names. */ async function listProjectsOutput(): Promise { - const dir = app.getPath('userData') - // Serve the ASSEMBLED v2-shaped workspace, never the raw workspace.json. Post-migration the file - // is a v3 index ({version:3, entries:[…]}) whose local-ref entries hold no node data at all — the - // paired iOS client decodes `{ projects: [Project] }`, so a raw v3 file lists zero projects. - // load() re-reads each ref's .nodeterm/project.json and returns {version:2, projects:[…]}; it is - // idempotent (and re-syncs the watcher via onPersist), so calling it here is safe. - const workspace = await workspaceStore - // Read-only: a phone listing projects mid git-merge must NOT sideline a conflict-marked - // project.json to `.corrupt-` (the probe/watcher-path fix); sideline is boot/renderer-only. - .load({ sideline: false }) - .then((w) => JSON.stringify(w)) - .catch(() => '') - const status = await readFile(join(dir, 'agent-status.json'), 'utf8').catch(() => '') - const sessions = (await ptyManager.listNodetermSessions().catch(() => [])).join('\n') - return `${workspace}\n${NT_PROJECTS_MARK}\n${sessions}\n${NT_STATUS_MARK}\n${status}` + return buildProjectsListBlob({ + workspace: workspaceStore, + userDataDir: app.getPath('userData'), + listSessions: () => ptyManager.listNodetermSessions() + }) } // Remote git routing is scoped to the ACTIVE project only (set via `git:set-active-remote`). @@ -760,6 +759,7 @@ let activeRemote: { cwd: string; ref: GitRemoteRef } | null = null // macOS close→dock-reopen cycle and silently swallows every send. // True from the first before-quit on: lets window close-events through (see hide-on-close). let quitting = false +let sshActionsService: SshActionsService | undefined // Confirm-before-quit gate. Set once the user has answered "Quit" in the dialog below, or when // a quit is app-initiated rather than user-initiated (auto-update restart) and should not be @@ -1796,6 +1796,12 @@ app.whenReady().then(async () => { // a remote tab derives the default worktree path from it, and the worktree lives on THIS host. corePlatform.handle(IPC.appUserDataDir, () => app.getPath('userData')) + // Revoking a bridged PEER must CUT THE LIVE SESSION, not just unpin it (revocation.ts): unpinning + // refuses only the NEXT handshake, while the open relay socket keeps full shell access — "the + // person I just removed is still sitting in my terminal, typing". The revoker closes every live + // session with that key — a peer desktop's (relay-host) and a phone's (the standing host) — and + // each close runs that host's peer teardown (presence leave → PtyManager.dropClient / killed + // PTYs). Shared by `remote:revoke-peer` and the phone pairing service's device revoke. // Phone pairing (nodeterm iOS "scan a QR" flow): a one-shot LAN listener that installs the // phone's Ed25519 key into ~/.ssh/authorized_keys. The completion result is forwarded to the // window over `pairing:done` so the settings section can show the paired/timeout state. @@ -1805,10 +1811,23 @@ app.whenReady().then(async () => { loadHostKeyPair: loadOrCreateKeyPair, relayEndpoint: RELAY_URL, apiBase: RELAY_API_BASE, - relayAllowed + relayAllowed, + // A phone that sends its relay key in the sealed /pair body is approved by the scan itself + // (audit A07): the same pin a SAS approval writes, so its first remote connect needs nobody at + // the desk. Revoking the device takes the pin away again AND cuts the relay session the phone + // has open, through the same registered-host revocation primitive as `remote:revoke-peer`. + pinRelayKey: (pub) => updateApprovedDevices((store) => pinDevice(store, pub)), + revokeRelayKey: (pub) => { + rememberRevokedStandingPhone(pub) + return revokePeerKey(pub, ['phone']) + }, + // A07-late: a key the pairing recorded without pinning (no relay leg at the scan) is pinned by + // the standing host on the phone's first relay handshake, asked inside the pin store's queue so + // a racing revoke cannot be undone (see pinApprovedDeviceIf). + pinRelayKeyIfPaired: (pub, stillPaired) => pinApprovedDeviceIf(pub, stillPaired) }, { - // A phone "Remove" must also revoke its RELAY trust: unpin and cut its live relay sessions. - // Which pin is that phone's is unknowable here, so every phone pin goes (peer-revoke.ts). + getPairingInterface: () => settingsStore.get().phonePairingInterface, + // Legacy pairings without a recorded box key still require the all-phone revoke fallback. revokePhoneRelayTrust: revokeAllPhones }) ipcMain.handle(IPC.pairingStart, () => @@ -1830,6 +1849,7 @@ app.whenReady().then(async () => { if (url) void shell.openExternal(url) }) ipcMain.handle(IPC.pairingListDevices, () => pairingService.listDevices()) + ipcMain.handle(IPC.pairingListNetworks, () => pairingService.listNetworks()) ipcMain.handle(IPC.pairingRevokeDevice, (_e, id: string) => pairingService.revokeDevice(id)) // Push webhook management. The proof of ownership is made HERE with the relay host secret key, // which never reaches the renderer; the minted token passes through to it exactly once and is @@ -1856,9 +1876,12 @@ app.whenReady().then(async () => { // is the one primitive: it unpins the key from every role store and closes every live session it // holds on every host surface (standing phone host, interactive phone host, Team Access). Host- // security control plane, so it stays on raw ipcMain: a remote peer must never be able to revoke. - ipcMain.handle(IPC.remoteRevokePeer, (_e, peerKeyB64: string) => - revokePeerKey(String(peerKeyB64), PIN_ROLES) - ) + ipcMain.handle(IPC.remoteRevokePeer, (_e, peerKeyB64: string) => { + const pub = String(peerKeyB64) + // A disconnected phone has no registered live session; retain the same-run redial refusal. + rememberRevokedStandingPhone(pub) + return revokePeerKey(pub, PIN_ROLES) + }) ipcMain.on(IPC.shellReveal, (_e, p: string) => { if (p) shell.showItemInFolder(p) @@ -2399,7 +2422,7 @@ app.whenReady().then(async () => { ...(localCodexCaps?.noDaemon === true ? { codexNoDaemon: true } : {}), claudeAccounts: (s.claudeAccounts ?? []) .filter((a) => !a.host && !a.pending) - .map((a) => ({ id: a.id, dir: claudeConfigDirFor(a.id) })), + .map((a) => mirrorClaudeAccount(a, claudeConfigDirFor(a.id))), // Derived binary names only — never the launch command/env (see core/mirror-custom-agents.ts). customAgents: mirrorCustomAgents(s.customAgents) } @@ -2627,7 +2650,7 @@ app.whenReady().then(async () => { ? { claudeAccounts: (s.claudeAccounts ?? []) .filter((a) => a.host === hostKey && !a.pending) - .map((a) => ({ id: a.id, dir: remoteAccountConfigDirAbs(home, a.id) })) + .map((a) => mirrorClaudeAccount(a, remoteAccountConfigDirAbs(home, a.id))) } : {}), // unresolved home ⇒ no accounts advertised (fail-open), autoSupported still ships // Settings-global, and the pane on the host runs the same launch command — so the same @@ -3161,6 +3184,48 @@ app.whenReady().then(async () => { // project: an SSH project's hook runs on the REMOTE host (write over its ControlMaster), a local // project's on THIS machine (write under os.homedir() — the hook uses $HOME, which may differ from // the project cwd). pendingId is validated before it is interpolated into any path/command. + // + // ONE writer for every answerer: the canvas IPC below and the relay phone's `approvals.answer` + // verb (host-service.ts `HostInboxOps`) both call `answerPermission`, so a phone answer can never + // take a different route than a desktop one. + // + // The writer checks the hold still exists (its `.json`) before writing: a late answer + // (the phone's usual case — the hold is 45 s) used to be reported, and broadcast, as delivered, + // clearing NEEDS YOU while the prompt still waited on screen (audit A06). `gone` vs `failed` is + // kept apart so the phone can say "open the session" rather than "already handled" (A35). + async function answerPermission( + nodeId: string, + pendingId: string, + decision: 'allow' | 'deny' | 'allow-always', + suggestionIndex?: number + ): Promise { + if (!isValidPendingId(pendingId)) return 'failed' + if (decision !== 'allow' && decision !== 'deny' && decision !== 'allow-always') return 'failed' + if (decision === 'allow-always') { + if (!workspaceStore.getNode(nodeId) || !hookTicketStillOpen(nodeId, pendingId, 'approval')) return 'gone' + const answer = { kind: 'allow-always' as const, suggestionIndex: suggestionIndex ?? -1 } + const sshProjectId = workspaceStore.sshProjectIdForNode(nodeId) + const result = sshProjectId ? await sshProjectManager?.answerPendingHook(sshProjectId, nodeId, pendingId, answer) ?? 'failed' + : await answerPendingHookLocal(nodeId, pendingId, answer, homedir()) + // Only the hook's consumed-answer POST settles a remembered-rule card. + return result + } + const sshProjectId = workspaceStore.sshProjectIdForNode(nodeId) + const result: PendingAnswerResult = sshProjectId + ? sshProjectManager + ? await sshProjectManager.answerPending(sshProjectId, pendingId, decision) + : 'failed' + : await answerPendingLocal(pendingId, decision, homedir()) + // Optimistic flip: on a successful write, emit the same synthetic "answered" transition the + // held hook's second POST will produce, so the NEEDS YOU badge clears instantly instead of + // waiting for that POST to round-trip. The later hook POST is an idempotent duplicate (a + // same-state working re-assert is a no-op). See docs/hook-reply-approvals.md. + if (result === 'sent') { + const ev = syntheticAnsweredEvent(nodeId, pendingId, decision) + if (ev) emitAgentStatus(ev) + } + return result + } // The held request's I/O for a node — shared with the phone's `agent.answer` (hostBridge.chat) so // both answer paths reach the SAME host the same way. const heldPermissionIoFor = (nodeId: string, pendingId: string): HeldPermissionIo => { @@ -3178,6 +3243,9 @@ app.whenReady().then(async () => { // The ONE answer body both shells share (core/agents/permission-decision.ts): read the held // request on the host the agent runs on, validate + build the decision there, write it back. // A structured answer is refused without a readable request; the legacy words are not. + if (payload.answer === undefined && payload.decision === 'allow-always') { + return (await answerPermission(nodeId, pendingId, payload.decision, payload.suggestionIndex)) === 'sent' + } const io = heldPermissionIoFor(nodeId, pendingId) const res = await answerHeldPermission(pendingId, { decision: payload.decision, answer: payload.answer }, io) // Optimistic flip: on a successful write, emit the same synthetic "answered" transition the @@ -3389,7 +3457,11 @@ app.whenReady().then(async () => { if (e.status !== 'connected' && e.status !== 'connecting') devPorts.registry?.projectDisconnected(e.projectId) }) const ackSweeper = createAckSweeper({ - handlers: { ackDone, onUnreadClear: (id) => sendToMain(IPC.agentUnreadClear, id) } + handlers: { + ownsNode: mirrorOwnsNode, + ackDone, + onUnreadClear: (id) => sendToMain(IPC.agentUnreadClear, id) + } }) let remoteAckSweepBusy = false const ackSweepTimer = setInterval(() => { @@ -4528,18 +4600,18 @@ app.whenReady().then(async () => { // invisible to every attention predicate — the kanban-modal gap, one surface further out); // - `agent:wake` fires on each attach, so a hibernated node someone just opened on their // phone resumes its CLI — the same nudge contract as `wakeHibernatedNode` (re-reads the - // flag, no-ops when not hibernated or not mounted). + // flag and verifies the saved pane even when the project is not mounted). remoteViewer: (() => { const counts = new Map() - const toRenderer = (channel: string, payload: unknown): void => { - if (!win.isDestroyed()) win.webContents.send(channel, payload) + const toRenderer = (channel: string, payload: unknown, automatic?: boolean): void => { + if (!win.isDestroyed()) win.webContents.send(channel, payload, automatic) } const broadcast = (): void => toRenderer(IPC.agentRemoteViewers, [...counts.keys()]) return { attached(nodeId: string) { counts.set(nodeId, (counts.get(nodeId) ?? 0) + 1) broadcast() - toRenderer(IPC.agentWake, nodeId) + toRenderer(IPC.agentWake, nodeId, true) }, detached(nodeId: string) { const n = (counts.get(nodeId) ?? 0) - 1 @@ -4566,9 +4638,58 @@ app.whenReady().then(async () => { return { wake: (nodeId: string) => deliver(IPC.agentWake, nodeId), refresh: (nodeId: string) => deliver(IPC.agentRefreshNode, nodeId), - rename: (nodeId: string, title: string) => deliver(IPC.agentRenameNode, { nodeId, title }) + rename: (nodeId: string, title: string) => deliver(IPC.agentRenameNode, { nodeId, title }), + // A quick answer typed WITHOUT a throwaway tmux client (audit A12): the painter pty or a + // control client, via the existing background write. LOCAL nodes only: host-service sends + // a node of an SSH project over its master instead (`remoteNodes` below → + // `ptyManager.backgroundWriteOver`), so it never gets here. The refusal stays as the + // backstop — a local write for such a node would type into this machine's socket, where + // its `nt-` is at best nothing — and `false` opens the session on the phone. + sendKeys: async (nodeId: string, keys: string) => + workspaceStore.sshProjectIdForNode(nodeId) ? false : ptyManager.backgroundWrite(nodeId, keys) } })(), + // A phone opening a node of an SSH project reaches THAT host's tmux over the project's master, + // or is told why not — never a phantom local session (audit A09). + remoteNodes: { + resolve: (nodeId: string) => { + const projectId = workspaceStore.sshProjectIdForNode(nodeId) + if (!projectId) return null + const ssh = workspaceStore.projectTargetInfo(projectId)?.ssh + const where = ssh?.server ? sshHostKey(ssh.server) : 'another computer' + const sshRemote = sshProjectManager?.sshRemoteFor(projectId, ssh?.remoteCwd) + return sshRemote ? { where, sshRemote } : { where } + } + }, + // A session the phone starts is created in its project's folder, under the account and agent it + // chose, owned by that project — all resolved HERE from the host's own registry and settings + // (audit A33: the phone's `cd`/env launch prefix is POSIX-only, so on Windows it was dropped; + // audit A72: without the agent the session kept a bare hook env for life, and without an owner + // its pane stayed unproven for agent messaging). See host-new-sessions.ts. + newSessions: createHostNewSessions({ + projectTargetInfo: (projectId) => workspaceStore.projectTargetInfo(projectId), + claudeAccounts: () => settingsStore.get().claudeAccounts ?? [], + persistedCanvases: () => workspaceStore.persistedCanvases(), + customAgents: () => settingsStore.get().customAgents ?? [] + }), + // A relay phone's Inbox actions (`approvals.answer` / `inbox.ack`): the SAME answer writer the + // canvas Approve/Deny button uses, and the SAME ack pair the `~/.nodeterm/acks` sweep runs for a + // phone on direct SSH — `ackDone` (resolve the done event, dismiss other phones' activities) + // plus the desktop unread clear, WITHOUT a re-ack (the external-clear channel). + inbox: { + answerPermission, + answerQuestion: async (nodeId: string, pendingId: string, selections: number[][]) => { + if (!workspaceStore.getNode(nodeId) || !hookTicketStillOpen(nodeId, pendingId, 'question')) return 'gone' + const answer = { kind: 'question' as const, selections } + const sshProjectId = workspaceStore.sshProjectIdForNode(nodeId) + return sshProjectId ? await sshProjectManager?.answerPendingHook(sshProjectId, nodeId, pendingId, answer) ?? 'failed' + : answerPendingHookLocal(nodeId, pendingId, answer, homedir()) + }, + ackRead: (nodeId: string) => { + ackDone(nodeId) + sendToMain(IPC.agentUnreadClear, nodeId) + } + }, // The phone's Chat screen (`chat.page` / `chat.status` / `chat.send` / `agent.answer`, // main/remote/host-chat.ts). Everything about the node comes from THIS machine's records: the // workspace store (cwd resolved as the canvas sees it, account, agent) and the status mirror @@ -4646,7 +4767,11 @@ app.whenReady().then(async () => { // Jail roots beyond the active canvas: the phone browses EVERY project (projects.list), so // its fs/git access spans every local project root — not just the tab the desktop happens // to have focused (that gap read as "cwd is outside the shared project roots" on the phone). - workspaceRoots: () => workspaceStore.localProjectCwds() + workspaceRoots: () => workspaceStore.localProjectCwds(), + // This computer's current LAN address and SSH host keys, beside every projects.list answer, so + // a phone that reached it through the relay can update the LAN leg it dials (audit A74-refresh: + // the pairing QR's address is a DHCP lease, and a reinstall regenerates sshd's keys). + lanReport: createHostLanReporter({ getPairingInterface: () => settingsStore.get().phonePairingInterface }) } // The renderer owns the Eco hibernation flag (persisted in ITS localStorage) and main only // mirrors it — same direction as `terminalFocused`. Feeds the agent-status mirror so the phone @@ -4656,6 +4781,23 @@ app.whenReady().then(async () => { if (typeof msg?.nodeId !== 'string' || !msg.nodeId) return setNodeHibernated(msg.nodeId, msg.on === true) }) + // Files are reachable only to the same OS user authenticated by SSH; never through hook bearers. + // Seed persisted ownership before advertising even if the renderer has not loaded a canvas yet. + await workspaceStore.load({ sideline: false }) + const managedPlanner = createManagedTerminalPlanner({ store: workspaceStore, + userData: corePlatform.userDataDir, settings: () => settingsStore.get(), trust: projectTrustStore, + executable: findInLoginPath, sessionShell: resolveLocalSessionShell, + claudeCaps: claudeCliCaps, codexCaps: codexCliCaps, grokCaps: grokCliCaps, + codexIdentity: codexIdentityCaps }) + const managedTerminals = ptyManager.supportsManagedCreation() ? new ManagedTerminals( + corePlatform.userDataDir, workspaceStore, { + supported: () => ptyManager.supportsManagedCreation(), ...managedPlanner, + create: (options, creationId, current) => ptyManager.createManagedHeadless(options, creationId, current), + verify: (receipt, current) => ptyManager.verifyManagedPane(receipt, current), + deliver: (receipt, command, current) => ptyManager.deliverManagedLaunch(receipt, command, current) + }) : undefined + sshActionsService = await startSshActionsService(corePlatform.userDataDir, workspaceStore, + hostBridge.nodeActions, true, managedTerminals) // Identity seed: the renderer's persisted agentStatus store reports the session ids it holds for // nodes the mirror has none for (an idle, terminal-made conversation after a restart), so the // phone's chat view can locate the transcript before the next hook fires. Add-only and @@ -4688,8 +4830,14 @@ app.whenReady().then(async () => { } }) // Standing (phone) relay host: keep a host connection registered so a paired phone can reach - // this Mac from anywhere. Honors settings.phoneAccessEnabled internally. - const standingHost = initStandingHost(win, ptyManager, () => settingsStore.get(), listProjectsOutput, hostBridge) + // this Mac from anywhere. Honors settings.phoneAccessEnabled internally. Revoking a phone reaches + // its open sessions through the peer-revoke registry, including pending consent dialogs. + const standingHost = initStandingHost(win, ptyManager, () => settingsStore.get(), listProjectsOutput, hostBridge, { + legacyRelayPairings: pairingService.legacyRelayPairings, + // A paired phone that adopted the relay after the scan is approved by its pairing record, not by + // a dialog at a desk it has left (A07-late). + pinPairedPhone: (pub) => pairingService.approvePairedRelayKey(pub) + }) ipcMain.on(IPC.remoteStandingHostSet, (_e, enabled: boolean) => standingHost.setEnabled(!!enabled)) // Reconcile from persisted settings on launch (starts hosting if enabled). standingHost.syncFromSettings() @@ -5103,6 +5251,7 @@ app.on('before-quit', (e) => { }) return } + void sshActionsService?.stop() // retire queued file-service writes before teardown quitting = true // from here on, window close-events must NOT be turned into hide destroyNotchHud() // Electron releases power assertions at exit anyway; disposing keeps the hold/release log diff --git a/src/main/paired-phone-late-pin-wiring.test.ts b/src/main/paired-phone-late-pin-wiring.test.ts new file mode 100644 index 0000000000..6d9f8db450 --- /dev/null +++ b/src/main/paired-phone-late-pin-wiring.test.ts @@ -0,0 +1,46 @@ +// A paired phone that adopts the relay after the scan is approved by its pairing record on its first +// relay handshake (audit A07-late), and two optional deps in this file's shell make that true +// together: +// +// - `PairingRelayDeps.pinRelayKeyIfPaired`: with it, the `/pair` answer promises `relayApproved` +// for a key the pairing only recorded, and the phone then lets its background check use the +// relay. It must run the check inside the pin store's queue (`pinApprovedDeviceIf`), or a revoke +// racing it can be undone (approved-devices.test.ts). +// - `StandingHostOptions.pinPairedPhone`: the standing host's question on an unpinned handshake. +// +// Both are optional and well-typed when absent, so dropping the second leaves every unit test green +// while the answer promises an approval the host never gives: the background check then dials with +// `requireApproved` and raises the SAS dialog on an empty desk, the failure A05 exists to prevent. +// Pinned at source level, like peer-revoke-wiring.test.ts. +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'fs' +import path from 'path' + +const SRC = readFileSync(path.join(__dirname, 'index.ts'), 'utf8').replace(/\r\n/g, '\n') + +/** The text of a call starting at `head`, up to its closing `})` at two-space indentation. */ +function call(head: string): string { + const start = SRC.indexOf(head) + expect(start, `${head} is not called; this guard is looking at the wrong file`).toBeGreaterThan(-1) + const end = SRC.indexOf('\n })', start) + expect(end).toBeGreaterThan(start) + return SRC.slice(start, end) +} + +describe('main wires both halves of the late pin of a paired phone', () => { + it('the pairing service checks inside the pin store queue', () => { + expect(call('createPairingService({')).toContain( + 'pinRelayKeyIfPaired: (pub, stillPaired) => pinApprovedDeviceIf(pub, stillPaired)' + ) + }) + + it('the standing host asks the pairing service on an unpinned handshake', () => { + expect(call('const standingHost = initStandingHost(')).toContain( + 'pinPairedPhone: (pub) => pairingService.approvePairedRelayKey(pub)' + ) + // The service it asks is the one pairing records the keys in, created before the host. + expect(SRC.indexOf('const pairingService = createPairingService(')).toBeLessThan( + SRC.indexOf('const standingHost = initStandingHost(') + ) + }) +}) diff --git a/src/main/pairing-core.test.ts b/src/main/pairing-core.test.ts index c3c895f864..1aa8a11a92 100644 --- a/src/main/pairing-core.test.ts +++ b/src/main/pairing-core.test.ts @@ -10,6 +10,7 @@ import { IOS_APP_KEY_COMMENT, keyCommentOf, filterAuthorizedKeys, + holdsPairedRelayKey, isValidEd25519PublicKey, normalizeAuthorizedKeysLine, normalizeDeviceName, @@ -384,6 +385,33 @@ describe('device registry helpers', () => { }) }) +describe('holdsPairedRelayKey (audit A07-late)', () => { + // What the standing host asks before pinning a phone it has not pinned: did a pairing that is + // still listed record this key from its sealed body? + const dev = (id: string, relayBoxKey?: string): DeviceEntry => ({ + id, + name: id, + token: `tok-${id}`, + pairedAt: 1, + lastSeenAt: 0, + ...(relayBoxKey ? { relayBoxKey } : {}) + }) + + it('matches only a key a listed pairing recorded, exactly', () => { + const devices = [dev('a', 'KEY-A'), dev('b'), dev('c', 'KEY-C')] + expect(holdsPairedRelayKey(devices, 'KEY-A')).toBe(true) + expect(holdsPairedRelayKey(devices, 'KEY-C')).toBe(true) + expect(holdsPairedRelayKey(devices, 'KEY-B')).toBe(false) + expect(holdsPairedRelayKey(devices, 'key-a')).toBe(false) + expect(holdsPairedRelayKey(removeDevice(devices, 'a'), 'KEY-A')).toBe(false) + }) + + it('an empty key never matches, not even a pairing that recorded none', () => { + expect(holdsPairedRelayKey([dev('a'), { ...dev('b'), relayBoxKey: '' }], '')).toBe(false) + expect(holdsPairedRelayKey([], 'KEY-A')).toBe(false) + }) +}) + describe('DeviceEntry.relayDeviceId', () => { // The desktop mints its OWN device id (it stamps the authorized_keys comment) and the phone // sends its own — and only the phone's is what the server keys `relay_devices` on. Keeping @@ -454,6 +482,19 @@ describe('pickLanIPv4', () => { expect(picked).toBe('192.168.1.42') }) + it('shares automatic physical-adapter preference and strict address rejection with QR/LAN refresh', () => { + expect(pickLanIPv4({ + docker0: [{ address: '172.17.0.1', family: 4, internal: false }], + wg0: [{ address: '10.7.0.2', family: 4, internal: false }], + wlan0: [{ address: '192.168.1.42', family: 4, internal: false }], + })).toBe('192.168.1.42') + expect(pickLanIPv4({ en0: [ + { address: '999.1.2.3', family: 4, internal: false }, + { address: '127.0.0.2', family: 4, internal: false }, + { address: '224.1.2.3', family: 4, internal: false }, + ] })).toBeNull() + }) + it('accepts the numeric family form (family: 4)', () => { expect( pickLanIPv4({ en0: [{ address: '10.0.0.2', family: 4, internal: false }] }) @@ -527,7 +568,13 @@ describe('pickPairingIPv4', () => { expect(pickPairingIPv4(ifaces, '10.9.9.9')).toBe('192.168.1.42') expect(pickPairingIPv4(ifaces, null)).toBe('192.168.1.42') }) - it('falls back to the old pick when only virtual adapters exist', () => { + it('accepts a current secondary adapter address as a hint, but rejects malformed/non-unicast hints', () => { + const current = { docker0: [nic('172.17.0.1'), nic('172.17.0.2')], wlan0: [nic('192.168.1.42')] } + expect(pickPairingIPv4(current, '172.17.0.2')).toBe('172.17.0.2') + expect(pickPairingIPv4({ bad: [nic('999.1.2.3')], wlan0: [nic('192.168.1.42')] }, '999.1.2.3')).toBe('192.168.1.42') + }) + + it('keeps a valid fallback when only virtual adapters exist', () => { expect(pickPairingIPv4({ 'vEthernet (WSL)': [nic('172.20.48.1')] }, null)).toBe('172.20.48.1') }) }) diff --git a/src/main/pairing-core.ts b/src/main/pairing-core.ts index 0ce2e1c24d..6db4b4478c 100644 --- a/src/main/pairing-core.ts +++ b/src/main/pairing-core.ts @@ -1,3 +1,5 @@ +import { pairingNetworkIPv4, type PairingNetworkAddress, type PairingInterfaces } from '../shared/pairing-network' + // Pure helpers for the phone-pairing service (no I/O), so they can be unit-tested with fakes. // // The QR payload + the authorized_keys line validation are the two bits of the pairing flow @@ -171,6 +173,16 @@ export interface DeviceEntry { * its presence names a row the server MAY hold, not one it certainly does. */ relayDeviceId?: string + /** + * The phone's relay (NaCl box) public key, base64, when it sent one inside the SEALED `/pair` + * body. Pairing pins it on the standing host when the pairing minted a relay leg (audit A07); + * otherwise the standing host pins it on the phone's first relay handshake while this entry is + * listed (A07-late: remote access turned on after the scan, the phone adopting the relay over + * SSH). Revoking the device unpins it again and closes the relay sessions it has open + * (A07-revoke). Recorded whether or not a relay leg was minted. + * Absent for pairings made before this field existed and for phones that do not send it. + */ + relayBoxKey?: string /** * `false` = no SSH key was installed for this device (a Windows pairing: the phone reaches this * host through the relay only). Absent on every pairing that did install one, including all @@ -243,6 +255,23 @@ export function upsertDevice(devices: DeviceEntry[], entry: DeviceEntry): Device return [...devices.filter((d) => d.id !== entry.id), entry] } +/** A NaCl box public key as base64: exactly 32 bytes. */ +export function isValidBoxPublicKeyB64(value: unknown): value is string { + if (typeof value !== 'string' || value.length > 64) return false + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return false + return Buffer.from(value, 'base64').length === 32 +} + +/** + * Does a listed pairing hold `boxKeyB64` as the relay key its phone sent in the sealed `/pair` body + * (A07-late)? What the standing host asks before pinning a phone it has not pinned yet: the scan that + * recorded the key is the approval, as at pairing. Exact string match against what pairing recorded, + * so a hand-edited entry carrying junk matches nothing. An empty key never matches. + */ +export function holdsPairedRelayKey(devices: DeviceEntry[], boxKeyB64: string): boolean { + return boxKeyB64.length > 0 && devices.some((d) => d.relayBoxKey === boxKeyB64) +} + /** Drop the device with the given id (no-op if absent). */ export function removeDevice(devices: DeviceEntry[], id: string): DeviceEntry[] { return devices.filter((d) => d.id !== id) @@ -262,57 +291,18 @@ export function toPublicDevices(devices: DeviceEntry[]): PublicDevice[] { })) } -/** Minimal shape of `os.networkInterfaces()` we need — kept structural so tests can fake it. */ -export interface NetInterfaceAddr { - address: string - family: string | number - internal: boolean -} +/** Compatibility export for callers using the pairing core's structural OS address type. */ +export type NetInterfaceAddr = PairingNetworkAddress -/** - * Pick a usable LAN IPv4 from `os.networkInterfaces()`, skipping internal (loopback) and - * link-local (169.254.x.x) addresses. Returns null when none is present. - */ -export function pickLanIPv4( - interfaces: Record -): string | null { - for (const addrs of Object.values(interfaces)) { - if (!addrs) continue - for (const a of addrs) { - const isV4 = a.family === 'IPv4' || a.family === 4 - if (!isV4 || a.internal) continue - if (a.address.startsWith('169.254.')) continue - return a.address - } - } - return null +/** Automatic pairing uses the same current-adapter policy as QR and LAN refresh. */ +export function pickLanIPv4(interfaces: PairingInterfaces): string | null { + return pairingNetworkIPv4(interfaces) } -/** - * The pairing host address on Windows, where the first adapter `os.networkInterfaces()` lists is - * routinely a virtual one the phone cannot reach (WSL / Hyper-V `vEthernet`, VirtualBox, VMware, - * VPN clients). `routeAddress` is the source address the OS picked for a route to the internet — - * i.e. the default-route adapter — and wins when it is a real non-internal IPv4 on this machine. - * Otherwise the first address on an adapter whose name does not look virtual, then - * `pickLanIPv4`'s old answer, so this never returns null where the old pick would not have. - */ +/** Windows' current OS route hint has precedence within the shared automatic policy. */ export function pickPairingIPv4( - interfaces: Record, + interfaces: PairingInterfaces, routeAddress: string | null ): string | null { - const usable = (a: NetInterfaceAddr): boolean => - (a.family === 'IPv4' || a.family === 4) && !a.internal && !a.address.startsWith('169.254.') - if (routeAddress) { - for (const addrs of Object.values(interfaces)) { - if (addrs?.some((a) => usable(a) && a.address === routeAddress)) return routeAddress - } - } - for (const [name, addrs] of Object.entries(interfaces)) { - if (!addrs || VIRTUAL_ADAPTER.test(name)) continue - const hit = addrs.find(usable) - if (hit) return hit.address - } - return pickLanIPv4(interfaces) + return pairingNetworkIPv4(interfaces, '', routeAddress) } - -const VIRTUAL_ADAPTER = /vEthernet|WSL|Hyper-V|VirtualBox|VMware|Loopback|Tailscale|ZeroTier|Npcap|TAP-|Docker/i diff --git a/src/main/pairing-legacy-key.test.ts b/src/main/pairing-legacy-key.test.ts new file mode 100644 index 0000000000..2c14be0c7c --- /dev/null +++ b/src/main/pairing-legacy-key.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, chmodSync, symlinkSync, renameSync } from 'fs' +import os from 'os' +import path from 'path' +let duringRead: (() => void) | null = null +let foreignOwner = false +let duringNamed: (() => void) | null = null +let namedReads = 0 +vi.mock('fs', async original => { + const fs = await original() + return { ...fs, lstatSync: (file: Parameters[0]) => { + if (duringNamed && String(file).endsWith('authorized_keys') && ++namedReads === 2) { + const effect = duringNamed; duringNamed = null; effect() + } + const value = fs.lstatSync(file) + if (foreignOwner && String(file).endsWith('authorized_keys')) value.uid += 1 + return value + }, readSync: (...args: Parameters) => { + const result = fs.readSync(...args) + duringRead?.(); duringRead = null + return result + } } +}) +import { readLegacyPairingKey, LEGACY_AUTHORIZED_KEYS_LIMIT } from './pairing-legacy-key' +const id = '11111111-1111-4111-8111-111111111111' +const raw = Buffer.alloc(32, 4) +const blob = Buffer.concat([Buffer.from('0000000b7373682d6564323535313900000020', 'hex'), raw]).toString('base64') +const line = `ssh-ed25519 ${blob} nodeterm-ios-${id}\n` +const homes: string[] = [] +function file(text = line): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'nt-legacy-public-')); homes.push(dir) + chmodSync(dir, 0o700) + const target = path.join(dir, 'authorized_keys'); writeFileSync(target, text, { mode: 0o600 }); return target +} +afterEach(() => { duringRead = null; duringNamed = null; namedReads = 0; foreignOwner = false; + for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) }) +describe('exact attributed public SSH key', () => { + it('reads only the exact pairing, preserving other public lines', () => { + expect(readLegacyPairingKey(file('# comment\nssh-rsa AAAA untouched\n' + line), id)).toBe(raw.toString('base64')) + expect(readLegacyPairingKey(file(line), '22222222-2222-4222-8222-222222222222')).toBeNull() + }) + it.each([line + line, line + line.replace('ssh-ed25519 ', 'restrict ssh-ed25519 '), + line + `from="one two" ${line}`, line.trimEnd() + '-suffix\n', line.replace('ssh-ed25519 ', 'restrict ssh-ed25519 '), + `ssh-ed25519 ${Buffer.from('0000000b7373682d65643235353139', 'hex').toString('base64')} nodeterm-ios-${id}\n`, + line.replace(blob, Buffer.concat([Buffer.from('0000000b7373682d656432353531390000001f', 'hex'), raw]).toString('base64'))])( + 'refuses duplicate, inexact, options or malformed attribution %#', text => expect(readLegacyPairingKey(file(text), id)).toBeNull()) + it('refuses a leaf link and a linked parent', () => { + const target = file(); const linked = target + '.link'; symlinkSync(target, linked) + expect(readLegacyPairingKey(linked, id)).toBeNull() + const parent = path.dirname(target) + '-link'; homes.push(parent); symlinkSync(path.dirname(target), parent) + expect(readLegacyPairingKey(path.join(parent, 'authorized_keys'), id)).toBeNull() + }) + it('refuses directories, oversized files and writable files/directories', () => { + const target = file(); expect(readLegacyPairingKey(path.dirname(target), id)).toBeNull() + const oversized = Buffer.alloc(LEGACY_AUTHORIZED_KEYS_LIMIT + 1, 35); Buffer.from(line).copy(oversized) + writeFileSync(target, oversized); expect(readLegacyPairingKey(target, id)).toBeNull() + writeFileSync(target, line); chmodSync(target, 0o666); expect(readLegacyPairingKey(target, id)).toBeNull() + chmodSync(target, 0o600); chmodSync(path.dirname(target), 0o777); expect(readLegacyPairingKey(target, id)).toBeNull() + }) + it('refuses a different named inode even when the opened file remains readable', () => { + const target = file(); const replacement = target + '.new'; writeFileSync(replacement, line, { mode: 0o600 }) + // Replace after the final descriptor metadata snapshot, immediately before the pathname + // snapshot. Actual rename semantics apply; only that scheduling boundary is controlled. + duringNamed = () => renameSync(replacement, target) + expect(readLegacyPairingKey(target, id)).toBeNull() + }) + it('refuses foreign ownership even when the public file can be read', () => { + const target = file(); foreignOwner = true + expect(readLegacyPairingKey(target, id)).toBeNull() + }) + it('refuses growth of the actual opened file during a capped read', () => { + const target = file() + duringRead = () => writeFileSync(target, line + line) + expect(readLegacyPairingKey(target, id)).toBeNull() + }) +}) diff --git a/src/main/pairing-legacy-key.ts b/src/main/pairing-legacy-key.ts new file mode 100644 index 0000000000..b20ad5778d --- /dev/null +++ b/src/main/pairing-legacy-key.ts @@ -0,0 +1,60 @@ +import { constants, closeSync, fstatSync, lstatSync, openSync, readSync } from 'fs' +import { createHash } from 'crypto' +import path from 'path' +import { deviceCommentFor, type DeviceEntry } from './pairing-core' + +export const LEGACY_AUTHORIZED_KEYS_LIMIT = 1024 * 1024 + +/** Fixed host paths only. Descriptor-bound, capped, owner-controlled regular-file snapshot. */ +export function readOwnedRegularFile(file: string, limit: number): Buffer | null { + let fd: number | undefined + try { + const directory = lstatSync(path.dirname(file)) + if (!directory.isDirectory() || directory.isSymbolicLink() || directory.mode & 0o022 || + (process.getuid && directory.uid !== process.getuid())) return null + const before = lstatSync(file) + if (!before.isFile() || before.isSymbolicLink() || before.size > limit || + before.mode & 0o022 || (process.getuid && before.uid !== process.getuid())) return null + fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | constants.O_NONBLOCK) + const opened = fstatSync(fd) + if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size) return null + const bytes = Buffer.alloc(opened.size + 1) + let count = 0 + while (count < bytes.length) { + const n = readSync(fd, bytes, count, bytes.length - count, null) + if (!n) break + count += n + } + const after = fstatSync(fd) + const named = lstatSync(file) + const namedDirectory = lstatSync(path.dirname(file)) + if (count !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || + after.ctimeMs !== opened.ctimeMs || named.dev !== opened.dev || named.ino !== opened.ino || !named.isFile() || + namedDirectory.dev !== directory.dev || namedDirectory.ino !== directory.ino || !namedDirectory.isDirectory()) return null + return bytes.subarray(0, count) + } catch { return null } finally { if (fd !== undefined) { try { closeSync(fd) } catch { /* refusal already returned */ } } } +} + +/** Public attribution only, never a private-key reader. */ +export function readLegacyPairingKey(file: string, pairingId: string): string | null { + try { + const bytes = readOwnedRegularFile(file, LEGACY_AUTHORIZED_KEYS_LIMIT) + if (!bytes) return null + const matches = bytes.toString('utf8').split('\n').filter(line => + line.trim().split(/\s+/).at(-1) === deviceCommentFor(pairingId)) + // Even identical duplicate attributions are ambiguous; no choice by ordering/signature. + if (matches.length !== 1) return null + const parts = matches[0].trim().split(/\s+/) + if (parts.length !== 3 || parts[0] !== 'ssh-ed25519' || !/^[A-Za-z0-9+/]+={0,2}$/.test(parts[1])) return null + const blob = Buffer.from(parts[1], 'base64') + if (blob.toString('base64') !== parts[1] || blob.length !== 51 || blob.readUInt32BE(0) !== 11 || + blob.subarray(4, 15).toString('ascii') !== 'ssh-ed25519' || blob.readUInt32BE(15) !== 32) return null + return blob.subarray(19).toString('base64') + } catch { return null } +} + +/** Private capability version, never sent to the phone or renderer. */ +export function legacyPairingEntryVersion(entry: DeviceEntry): string { + return createHash('sha256').update(JSON.stringify([entry.id, entry.name, entry.token, entry.pairedAt, + entry.lastSeenAt, entry.relayDeviceId ?? null, entry.ssh ?? null, entry.relayBoxKey ?? null])).digest('hex') +} diff --git a/src/main/pairing-revoke.test.ts b/src/main/pairing-revoke.test.ts index 201f93f9cc..2dd2360d29 100644 --- a/src/main/pairing-revoke.test.ts +++ b/src/main/pairing-revoke.test.ts @@ -47,13 +47,14 @@ assertTempHome() const KEY_A = rewriteKeyComment('ssh-ed25519 AAAAblobAAAA phone-a@ios', 'dev-a') const KEY_OTHER = 'ssh-rsa AAAAlaptopblob jdub@laptop' -const device = (id: string, relayDeviceId?: string): DeviceEntry => ({ +const device = (id: string, relayDeviceId?: string, relayBoxKey?: string): DeviceEntry => ({ id, name: `Phone ${id}`, token: `agent-token-${id}`, pairedAt: 1_700_000_000_000, lastSeenAt: 0, - relayDeviceId + relayDeviceId, + ...(relayBoxKey ? { relayBoxKey } : {}) }) /** Seed a registry holding ONE device; `relayDeviceId` is what the phone called itself. */ @@ -184,7 +185,7 @@ describe('createPairingService().revokeDevice', () => { vi.stubGlobal('fetch', fetchMock) const service = createPairingService(relayDeps('TOKEN')) - expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'ok' }) + expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'ok', relay: 'unconfirmed' }) // 'dev-a' is OUR id; the backend keys its row on the id the phone sent. expect(fetchBody(fetchMock)).toEqual({ deviceId: 'phone-relay-1', entitlement: 'TOKEN' }) @@ -203,7 +204,7 @@ describe('createPairingService().revokeDevice', () => { vi.stubGlobal('fetch', fetchMock) const service = createPairingService(relayDeps('TOKEN')) - expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'ok' }) + expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'ok', relay: 'unconfirmed' }) expect(fetchBody(fetchMock)).toEqual({ deviceId: 'dev-a', entitlement: 'TOKEN' }) expect(deviceIds()).toEqual([]) }) @@ -213,7 +214,7 @@ describe('createPairingService().revokeDevice', () => { vi.stubGlobal('fetch', fetchMock) const service = createPairingService(relayDeps(null)) - expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'skipped' }) + expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'skipped', relay: 'unconfirmed' }) expect(fetchMock).not.toHaveBeenCalled() expect(deviceIds()).toEqual([]) }) @@ -223,7 +224,7 @@ describe('createPairingService().revokeDevice', () => { vi.stubGlobal('fetch', fetchMock) const service = createPairingService() - expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'skipped' }) + expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'skipped', relay: 'unconfirmed' }) expect(fetchMock).not.toHaveBeenCalled() }) @@ -231,7 +232,7 @@ describe('createPairingService().revokeDevice', () => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 403 }))) const service = createPairingService(relayDeps('TOKEN')) - expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'failed' }) + expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'failed', relay: 'unconfirmed' }) expect(deviceIds()).toEqual([]) expect(authKeys()).toBe(`${KEY_OTHER}\n`) }) @@ -264,6 +265,54 @@ describe('createPairingService().revokeDevice', () => { expect(await service.revokeDevice('never-paired')).toEqual({ local: true, server: 'skipped' }) expect(fetchMock).not.toHaveBeenCalled() }) + + it('reports an unknown legacy relay identity without guessing a key or calling the revoker', async () => { + const revokeRelayKey = vi.fn(async () => ({ persisted: true, killed: true })) + const service = createPairingService({ ...relayDeps(null), revokeRelayKey }) + + expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'skipped', relay: 'unconfirmed' }) + expect(revokeRelayKey).not.toHaveBeenCalled() + expect(deviceIds()).toEqual([]) + expect(authKeys()).toBe(`${KEY_OTHER}\n`) + }) + + it('cannot confirm a known relay identity revoked when no revoker is available', async () => { + const box = Buffer.alloc(32, 7).toString('base64') + writeFileSync(AGENT_JSON, JSON.stringify({ devices: [device('dev-a', 'phone-relay-1', box)] }), { mode: 0o600 }) + const service = createPairingService(relayDeps(null)) + + expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'skipped', relay: 'unconfirmed' }) + expect(deviceIds()).toEqual([]) + expect(authKeys()).toBe(`${KEY_OTHER}\n`) + }) + + it('reports retained relay access and does not close it while another pairing authorizes the same phone', async () => { + const box = Buffer.alloc(32, 7).toString('base64') + writeFileSync(AGENT_JSON, JSON.stringify({ devices: [ + device('dev-a', 'phone-relay-1', box), device('dev-b', 'phone-relay-1', box) + ] }), { mode: 0o600 }) + const revokeRelayKey = vi.fn(async () => ({ persisted: true, killed: true })) + const service = createPairingService({ ...relayDeps(null), revokeRelayKey }) + + expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'skipped', relay: 'retained' }) + expect(revokeRelayKey).not.toHaveBeenCalled() + expect(deviceIds()).toEqual(['dev-b']) + }) + + it('reports confirmed relay revocation only after unpin and session closure for the exact last key', async () => { + const box = Buffer.alloc(32, 7).toString('base64') + writeFileSync(AGENT_JSON, JSON.stringify({ devices: [device('dev-a', 'phone-relay-1', box)] }), { mode: 0o600 }) + const revokeRelayKey = vi.fn(async (key: string) => { + expect(key).toBe(box) + expect(deviceIds()).toEqual([]) + expect(authKeys()).toBe(`${KEY_OTHER}\n`) + return { persisted: true, killed: true } + }) + const service = createPairingService({ ...relayDeps(null), revokeRelayKey }) + + expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'skipped', relay: 'ok' }) + expect(revokeRelayKey).toHaveBeenCalledExactlyOnceWith(box) + }) }) describe('createPairingService().revokeDevice — relay trust (pinned box key + live sessions)', () => { diff --git a/src/main/pairing-service.legacy-proof.test.ts b/src/main/pairing-service.legacy-proof.test.ts new file mode 100644 index 0000000000..2ed02a3332 --- /dev/null +++ b/src/main/pairing-service.legacy-proof.test.ts @@ -0,0 +1,150 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { promises as fs, mkdirSync, readFileSync, rmSync, writeFileSync, readdirSync, symlinkSync } from 'fs' +import path from 'path' +import { generateKeyPairSync, sign } from 'crypto' +vi.mock('os', async original => { + const actual = await original() + const { mkdtempSync } = await import('fs'), { join } = await import('path') + const home = mkdtempSync(join(actual.tmpdir(), 'nt-legacy-pairing-')) + const base = (actual as unknown as { default?: typeof actual }).default ?? actual + return { ...actual, homedir: () => home, default: { ...base, homedir: () => home } } +}) +vi.mock('../core/device-id', () => ({ getDeviceId: () => 'test-host' })) +import os from 'os' +import { createPairingService } from './pairing-service' +import { createRelayPairingProof } from './remote/relay-pairing-proof' +import { relayPairingChallengeText, type RelayPairingChallengeResult } from '../shared/relay-pairing-proof' +import { type DeviceEntry } from './pairing-core' + +const scratch = os.homedir() +if (!path.basename(scratch).startsWith('nt-legacy-pairing-')) throw new Error('Refusing a non-fixture pairing home.') +const agentFile = path.join(scratch, '.nodeterm/agent.json'), publicFile = path.join(scratch, '.ssh/authorized_keys') +const id = '11111111-1111-4111-8111-111111111111', otherId = '22222222-2222-4222-8222-222222222222' +const keys = generateKeyPairSync('ed25519'), raw = keys.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32) +const blob = Buffer.concat([Buffer.from('0000000b7373682d6564323535313900000020', 'hex'), raw]).toString('base64') +const peer = Buffer.alloc(32, 5).toString('base64') +const entry = (deviceId = id): DeviceEntry => ({ id: deviceId, name: 'Test phone', token: `inert-private-${deviceId}`, pairedAt: 123, lastSeenAt: 0 }) +const state = () => JSON.parse(readFileSync(agentFile, 'utf8')) as { keep: string; devices: DeviceEntry[] } +const save = (devices: DeviceEntry[]) => writeFileSync(agentFile, JSON.stringify({ keep: 'untouched', devices }) + '\n', { mode: 0o600 }) +const publicLine = (keyBlob = blob) => `ssh-ed25519 ${keyBlob} nodeterm-ios-${id}\n` +beforeEach(() => { + mkdirSync(path.dirname(agentFile), { recursive: true, mode: 0o700 }); mkdirSync(path.dirname(publicFile), { recursive: true, mode: 0o700 }) + save([entry(), entry(otherId)]); writeFileSync(publicFile, publicLine() + 'ssh-rsa AAAA unrelated\n', { mode: 0o600 }) +}) +afterEach(() => { vi.restoreAllMocks(); rmSync(path.dirname(agentFile), { recursive: true, force: true }); rmSync(path.dirname(publicFile), { recursive: true, force: true }) }) +afterAll(() => rmSync(scratch, { recursive: true, force: true })) +async function prepared() { + const service = createPairingService() + const inspected = await service.legacyRelayPairings.inspect(id, peer) + expect(inspected.status).toBe('eligible') + if (inspected.status !== 'eligible') throw new Error('fixture key missing') + return { service, target: inspected.target } +} +async function stagingBarrier() { + const original = fs.chmod.bind(fs) + let entered!: () => void, release!: () => void + const paused = new Promise(resolve => { entered = resolve }) + const resume = new Promise(resolve => { release = resolve }) + vi.spyOn(fs, 'chmod').mockImplementation(async (file, mode) => { + await original(file, mode) + if (String(file).includes('agent.json.') && String(file).endsWith('.tmp')) { entered(); await resume } + }) + return { paused, release } +} +describe('legacy pairing association publication', () => { + it('repairs exactly one legacy entry and its next revoke uses only the proven relay key', async () => { + const revoked: string[] = [] + const service = createPairingService({ getSettings: () => ({}) as never, getEntitlement: () => null, + loadHostKeyPair: async () => { throw new Error('not used') }, relayEndpoint: 'wss://fixture', apiBase: '', + relayAllowed: () => false, revokeRelayKey: async key => { revoked.push(key); return { persisted: true, killed: true } } }) + const found = await service.legacyRelayPairings.inspect(id, peer) + if (found.status !== 'eligible') throw new Error('fixture') + expect(await service.legacyRelayPairings.associate(found.target, peer, () => true)).toEqual({ status: 'associated' }) + expect(state()).toEqual({ keep: 'untouched', devices: [{ ...entry(), relayBoxKey: peer }, entry(otherId)] }) + expect(readFileSync(publicFile, 'utf8')).toBe(publicLine() + 'ssh-rsa AAAA unrelated\n') + expect(revoked).toEqual([]) + expect((await service.revokeDevice(id)).relay).toBe('ok'); expect(revoked).toEqual([peer]) + expect(state().devices).toEqual([entry(otherId)]) + }) + it('leaves a same-key sibling authorization retained on revoke', async () => { + const { service, target } = await prepared() + save([entry(), { ...entry(otherId), relayBoxKey: peer }]) + expect(await service.legacyRelayPairings.associate(target, peer, () => true)).toEqual({ status: 'associated' }) + expect((await service.revokeDevice(id)).relay).toBe('retained') + expect(state().devices[0].relayBoxKey).toBe(peer) + }) + it('refuses gone, duplicate, non-SSH, conflicting and malformed identities', async () => { + const service = createPairingService() + expect(await service.legacyRelayPairings.inspect(otherId, peer)).toEqual({ status: 'unprovable' }) + expect(await service.legacyRelayPairings.inspect('bad-id', peer)).toEqual({ status: 'unprovable' }) + save([]); expect(await service.legacyRelayPairings.inspect(id, peer)).toEqual({ status: 'gone' }) + save([entry(), entry()]); expect(await service.legacyRelayPairings.inspect(id, peer)).toEqual({ status: 'unprovable' }) + save([{ ...entry(), ssh: false }]); expect(await service.legacyRelayPairings.inspect(id, peer)).toEqual({ status: 'unprovable' }) + save([{ ...entry(), relayBoxKey: Buffer.alloc(32, 8).toString('base64') }]); expect(await service.legacyRelayPairings.inspect(id, peer)).toEqual({ status: 'conflict' }) + save([{ ...entry(), relayBoxKey: peer }]); expect(await service.legacyRelayPairings.inspect(id, peer)).toEqual({ status: 'associated' }) + }) + it('cannot restore a pairing revoked after challenge inspection', async () => { + const { service, target } = await prepared(); await service.revokeDevice(id) + expect(await service.legacyRelayPairings.associate(target, peer, () => true)).toEqual({ status: 'gone' }) + expect(state().devices).toEqual([entry(otherId)]) + }) + it('refuses replaced private entry facts and current attributed keys', async () => { + const { service, target } = await prepared(); save([{ ...entry(), token: 'different-inert-token' }, entry(otherId)]) + const staged = vi.spyOn(fs, 'writeFile') + expect(await service.legacyRelayPairings.associate(target, peer, () => true)).toEqual({ status: 'unprovable' }) + expect(staged).not.toHaveBeenCalled() + save([entry(), entry(otherId)]); writeFileSync(publicFile, publicLine(Buffer.concat([ + Buffer.from('0000000b7373682d6564323535313900000020', 'hex'), Buffer.alloc(32, 9)]).toString('base64'))) + expect(await service.legacyRelayPairings.associate(target, peer, () => true)).toEqual({ status: 'unprovable' }) + expect(staged).not.toHaveBeenCalled() + expect(state().devices[0].relayBoxKey).toBeUndefined() + }) + it('does not publish if the exact session stops while its private temp is staged', async () => { + const { service, target } = await prepared(), barrier = await stagingBarrier(); let live = true + const before = readFileSync(agentFile, 'utf8') + const operation = service.legacyRelayPairings.associate(target, peer, () => live) + await barrier.paused; live = false; barrier.release() + expect(await operation).toEqual({ status: 'expired' }); expect(readFileSync(agentFile, 'utf8')).toBe(before) + expect(readdirSync(path.dirname(agentFile))).toEqual(['agent.json']) + }) + it('refuses file/key replacement during staging without overwriting unrelated new state', async () => { + const { service, target } = await prepared(), barrier = await stagingBarrier() + const operation = service.legacyRelayPairings.associate(target, peer, () => true) + await barrier.paused; save([entry(), { ...entry(otherId), name: 'new unrelated name' }]); barrier.release() + expect(await operation).toEqual({ status: 'unprovable' }); expect(state().devices[1].name).toBe('new unrelated name') + expect(state().devices[0].relayBoxKey).toBeUndefined() + }) + it('refuses attributed-key replacement during staging', async () => { + const { service, target } = await prepared(), barrier = await stagingBarrier() + const operation = service.legacyRelayPairings.associate(target, peer, () => true) + await barrier.paused; writeFileSync(publicFile, publicLine(Buffer.concat([ + Buffer.from('0000000b7373682d6564323535313900000020', 'hex'), Buffer.alloc(32, 9)]).toString('base64'))); barrier.release() + expect(await operation).toEqual({ status: 'unprovable' }); expect(state().devices[0].relayBoxKey).toBeUndefined() + }) + it('refuses a replaced private registry symlink during staging', async () => { + const { service, target } = await prepared(), barrier = await stagingBarrier() + const operation = service.legacyRelayPairings.associate(target, peer, () => true) + await barrier.paused + const replacement = agentFile + '.replacement'; writeFileSync(replacement, readFileSync(agentFile), { mode: 0o600 }) + rmSync(agentFile); symlinkSync(replacement, agentFile); barrier.release() + expect(await operation).toEqual({ status: 'unprovable' }); expect(state().devices[0].relayBoxKey).toBeUndefined() + }) + it('checks proof expiry again after actual asynchronous staging', async () => { + const service = createPairingService(); let now = 1_800_000_000_000, mono = 0 + const proof = createRelayPairingProof({ hostKey: Buffer.alloc(32, 1).toString('base64'), peerKey: () => peer, + current: () => true, pairings: service.legacyRelayPairings, now: () => now, monotonicNow: () => mono }) + const result = await proof.challenge({ pairingId: id }) + const c = (result as Extract).challenge + const barrier = await stagingBarrier() + const operation = proof.proof({ challengeId: c.challengeId, signatureB64: sign(null, + Buffer.from(relayPairingChallengeText(c)), keys.privateKey).toString('base64') }) + await barrier.paused; mono = 60_000; now -= 10_000; barrier.release() + expect(await operation).toEqual({ status: 'expired' }); expect(state().devices[0].relayBoxKey).toBeUndefined() + }) + it('preserves the registry and removes its temp if staging fails', async () => { + const { service, target } = await prepared(); const before = readFileSync(agentFile, 'utf8') + vi.spyOn(fs, 'chmod').mockRejectedValueOnce(new Error('fixture chmod failure')) + await expect(service.legacyRelayPairings.associate(target, peer, () => true)).rejects.toThrow('fixture chmod failure') + expect(readFileSync(agentFile, 'utf8')).toBe(before); expect(readdirSync(path.dirname(agentFile))).toEqual(['agent.json']) + }) +}) diff --git a/src/main/pairing-service.network.test.ts b/src/main/pairing-service.network.test.ts new file mode 100644 index 0000000000..26010f434e --- /dev/null +++ b/src/main/pairing-service.network.test.ts @@ -0,0 +1,123 @@ +// Actual PairingService HTTP listener and payload; only the OS adapter list, SSH probe and +// homedir are isolated. No POST installs a key or contacts the hosted relay. +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import { get as httpGet } from 'http' +import { rmSync } from 'fs' +import path from 'path' + +vi.mock('os', async (load) => { + const actual = await load() + const { mkdtempSync } = await import('fs') + const { join } = await import('path') + const home = mkdtempSync(join(actual.tmpdir(), 'nt-pairing-network-')) + const base = (actual as unknown as { default?: typeof actual }).default ?? actual + return { ...actual, homedir: () => home, default: { ...base, homedir: () => home } } +}) +vi.mock('net', async (load) => { + const actual = await load() + const { EventEmitter } = await import('events') + return { ...actual, connect: () => { + const socket = Object.assign(new EventEmitter(), { destroy: () => {}, setTimeout: () => {} }) + queueMicrotask(() => socket.emit('error', new Error('isolated SSH probe'))) + return socket + } } +}) + +import os from 'os' +import { createPairingService, type PairingService } from './pairing-service' +import type { PairingInterfaces } from '../shared/pairing-network' + +const services: PairingService[] = [] +const interfaces = (): PairingInterfaces => ({ + docker0: [{ address: '172.17.0.1', family: 'IPv4', internal: false }], + wg0: [{ address: '10.7.0.2', family: 4, internal: false }], + wlan0: [{ address: '192.168.1.42', family: 'IPv4', internal: false }], +}) +const service = (options: Parameters[1]): PairingService => { + const created = createPairingService(undefined, { ...options, sshHostKeyDirs: [path.join(os.homedir(), 'fixture-no-hostkeys')] }) + services.push(created) + return created +} +afterEach(() => services.splice(0).forEach((created) => created.stop())) +afterAll(() => { + const home = os.homedir() + if (!path.basename(home).startsWith('nt-pairing-network-')) throw new Error('foreign homedir') + rmSync(home, { recursive: true, force: true }) +}) +const get = (port: number): Promise => new Promise((resolve, reject) => { + const request = httpGet({ host: '127.0.0.1', port, path: '/pair', agent: false }, (response) => { + response.resume() + response.on('end', () => resolve(response.statusCode)) + }) + request.on('error', reject) +}) +async function payload(created: PairingService): Promise<{ host: string; pairPort: number; ssh?: false }> { + return JSON.parse((await created.start(() => {})).payload) +} + +describe('PairingService current adapter selection', () => { + it('lists the real injected interfaces afresh and stores no IP snapshot', () => { + let current = interfaces() + const created = service({ interfaces: () => current }) + expect(created.listNetworks()).toEqual([ + { interfaceName: 'docker0', address: '172.17.0.1' }, + { interfaceName: 'wg0', address: '10.7.0.2' }, + { interfaceName: 'wlan0', address: '192.168.1.42' }, + ]) + current = { wlan0: [{ address: '192.168.1.77', family: 4, internal: false }] } + expect(created.listNetworks()).toEqual([{ interfaceName: 'wlan0', address: '192.168.1.77' }]) + }) + + it('advertises the LAN adapter ahead of earlier Docker and tunnel addresses in an actual HTTP QR', async () => { + const route = vi.fn(async () => '10.7.0.2') + const created = service({ platform: 'linux', interfaces, defaultRouteAddress: route }) + const qr = await payload(created) + expect(qr.host).toBe('192.168.1.42') + expect(await get(qr.pairPort)).toBe(404) + expect(route).not.toHaveBeenCalled() + }) + + it('follows explicit interface selection and its current DHCP address at each actual start', async () => { + let current = interfaces() + let selected = 'wg0' + const created = service({ platform: 'darwin', interfaces: () => current, getPairingInterface: () => selected }) + expect((await payload(created)).host).toBe('10.7.0.2') + current = { ...current, wg0: [{ address: '10.7.0.9', family: 4, internal: false }] } + expect((await payload(created)).host).toBe('10.7.0.9') + selected = 'wlan0' + expect((await payload(created)).host).toBe('192.168.1.42') + }) + + it('retires the old listener and refuses a missing explicit adapter instead of advertising a different one', async () => { + let selected = 'wlan0' + const created = service({ interfaces, getPairingInterface: () => selected }) + const prior = await payload(created) + selected = 'en9' + await expect(created.start(() => {})).rejects.toThrow('selected pairing network “en9” has no usable IPv4') + await expect(get(prior.pairPort)).rejects.toMatchObject({ code: 'ECONNREFUSED' }) + }) + + it('keeps a valid Windows route hint, but an explicit adapter takes precedence', async () => { + let selected = '' + const route = vi.fn(async () => '10.7.0.2') + const created = service({ platform: 'win32', interfaces, getPairingInterface: () => selected, + defaultRouteAddress: route, detectKeyFile: async () => 'profile' }) + expect(await payload(created)).toMatchObject({ host: '10.7.0.2', ssh: false }) + selected = 'wlan0' + expect(await payload(created)).toMatchObject({ host: '192.168.1.42', ssh: false }) + expect(route).toHaveBeenCalledTimes(1) + }) + + it('rereads the saved choice and current addresses after a slow Windows route hint', async () => { + let selected = '' + let current = interfaces() + let release!: (address: string) => void + const created = service({ platform: 'win32', interfaces: () => current, getPairingInterface: () => selected, + defaultRouteAddress: () => new Promise((resolve) => { release = resolve }), detectKeyFile: async () => 'profile' }) + const pending = payload(created) + selected = 'wlan0' + current = { wlan0: [{ address: '192.168.1.77', family: 4, internal: false }] } + release('10.7.0.2') + expect(await pending).toMatchObject({ host: '192.168.1.77', ssh: false }) + }) +}) diff --git a/src/main/pairing-service.test.ts b/src/main/pairing-service.test.ts index 9439c3939a..ca1df5ff27 100644 --- a/src/main/pairing-service.test.ts +++ b/src/main/pairing-service.test.ts @@ -45,7 +45,7 @@ vi.mock('../core/device-id', () => ({ getDeviceId: () => 'test-host-device-id' } import os from 'os' import { createPairingService, type PairingRelayDeps } from './pairing-service' import { rewriteKeyComment, type DeviceEntry } from './pairing-core' -import { genKeyPair, publicKeyToB64 } from './remote/e2ee' +import { decrypt, deriveSharedKey, encrypt, genKeyPair, publicKeyToB64, type KeyPair } from './remote/e2ee' import type { Settings } from '../shared/types' const HOME = os.homedir() @@ -201,6 +201,25 @@ afterAll(() => { }) describe('revokeDevice', () => { + it('uses the phone-only relay fallback for a legacy entry and leaves a stale id out of scope', async () => { + const revoke = vi.fn(async () => ({ persisted: true, killed: true })) + const service = createPairingService(undefined, { revokePhoneRelayTrust: revoke }) + expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'skipped', relay: 'ok' }) + expect(revoke).toHaveBeenCalledOnce() + await service.revokeDevice('missing') + expect(revoke).toHaveBeenCalledOnce() + expect(deviceIds()).toEqual(['dev-b']) + }) + + it('keeps a legacy device and its SSH access retryable when relay trust cannot be revoked', async () => { + const service = createPairingService(undefined, { + revokePhoneRelayTrust: async () => ({ persisted: false, killed: true }) + }) + expect((await service.revokeDevice('dev-a')).local).toBe(false) + expect(deviceIds()).toContain('dev-a') + expect(authKeys()).toContain('nodeterm-ios-dev-a') + }) + // revokeDevice is a read-modify-write over BOTH files (agent.json, then authorized_keys) and // src/main/index.ts hands every `pairing:revoke-device` invoke straight to it, unserialized. Two // overlapping revokes each read the ORIGINAL file and filter out only their own device, so @@ -416,6 +435,523 @@ describe('pairing remembers the phone’s relay device id', () => { }) }) +/** POST /pair SEALED, the way the Android/iOS app does: `{epk, box}` to the QR's host key. */ +async function postSealedTo(hostKeys: KeyPair, port: number, body: unknown): Promise> { + const eph = genKeyPair() + const shared = deriveSharedKey(publicKeyToB64(hostKeys.publicKey), eph.secretKey) + const box = encrypt(Uint8Array.from(Buffer.from(JSON.stringify(body), 'utf8')), shared) + const text = await post(port, { epk: publicKeyToB64(eph.publicKey), box: Buffer.from(box).toString('base64') }) + const outer = JSON.parse(text) as { box: string } + const plain = decrypt(Uint8Array.from(Buffer.from(outer.box, 'base64')), shared) + return JSON.parse(Buffer.from(plain!).toString('utf8')) +} + +describe('pairing pins the phone relay key it sent sealed (audit A07)', () => { + // Pin-once used to mean "approve on the first relay connect", which the Auto route makes happen + // away from the desk. The scan is already the human's approval — the same one-time token that + // installs an SSH key — so the phone's box key, sent INSIDE the sealed body, is pinned here. + const hostKeys = genKeyPair() + const phoneBox = publicKeyToB64(genKeyPair().publicKey) + const pins: string[] = [] + // Every key handed to `revokeRelayKey` — in the app, the peer revoker that unpins it AND closes + // the relay sessions it has open (peer-revoker.ts; the cut itself is tested in standing-host.test). + const revokes: string[] = [] + let revokeOutcome: () => Promise<{ persisted: boolean; killed: boolean }> = async () => ({ persisted: true, killed: true }) + const relayDeps = (withPin = true): PairingRelayDeps => ({ + getSettings: () => ({ phoneAccessEnabled: true }) as unknown as Settings, + getEntitlement: () => null, + loadHostKeyPair: async () => hostKeys, + relayEndpoint: 'wss://relay.example/ws', + apiBase: 'https://api.example', + relayAllowed: () => true, + ...(withPin + ? { + pinRelayKey: async (pub: string) => void pins.push(pub), + revokeRelayKey: async (pub: string) => (revokes.push(pub), revokeOutcome()) + } + : {}) + }) + + const postSealed = (port: number, body: unknown): Promise> => postSealedTo(hostKeys, port, body) + + beforeEach(() => { + pins.length = 0 + revokes.length = 0 + revokeOutcome = async () => ({ persisted: true, killed: true }) + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ deviceToken: 'device-token', hostId: 'host-id', exp: 0 }) + } as unknown as Response) + }) + + it('pins the sealed box key, says so, records it, and revokes it (pin and session) on revoke', async () => { + const service = createPairingService(relayDeps()) + try { + const { token, pairPort } = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const resp = await postSealed(pairPort, { token, publicKey: freshEd25519Line(), deviceId: 'p1', boxPublicKey: phoneBox }) + expect(resp.relayPinned).toBe(true) + expect(pins).toEqual([phoneBox]) + const entry = ((agentJson().devices as DeviceEntry[]) ?? []).find((d) => d.id === resp.deviceId)! + expect(entry.relayBoxKey).toBe(phoneBox) + await service.revokeDevice(String(resp.deviceId)) + expect(revokes).toEqual([phoneBox]) + } finally { + service.stop() + } + }) + + it('keeps the pin while another pairing of the same phone is still listed', async () => { + const fallback = vi.fn(async () => ({ persisted: true, killed: true })) + const service = createPairingService(relayDeps(), { revokePhoneRelayTrust: fallback }) + try { + const first = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const a = await postSealed(first.pairPort, { token: first.token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox }) + const second = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + await postSealed(second.pairPort, { token: second.token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox }) + // Neither unpinned nor cut: the remaining pairing still authorizes this phone's relay session. + expect(await service.revokeDevice(String(a.deviceId))).toEqual({ local: true, server: 'skipped', relay: 'retained' }) + expect(revokes).toEqual([]) + expect(fallback).not.toHaveBeenCalled() + } finally { + service.stop() + } + }) + + it('revokes only the forgotten phone’s key; another phone keeps its pin and its session', async () => { + const otherBox = publicKeyToB64(genKeyPair().publicKey) + const service = createPairingService(relayDeps()) + try { + const s1 = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const a = await postSealed(s1.pairPort, { token: s1.token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox }) + const s2 = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const b = await postSealed(s2.pairPort, { token: s2.token, publicKey: freshEd25519Line(), boxPublicKey: otherBox }) + await service.revokeDevice(String(a.deviceId)) + expect(revokes).toEqual([phoneBox]) + await service.revokeDevice(String(b.deviceId)) + expect(revokes).toEqual([phoneBox, otherBox]) + } finally { + service.stop() + } + }) + + it('reports the relay leg: done, or unpinned with a session it could not confirm closed', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const service = createPairingService(relayDeps()) + try { + const pair = async () => { + const s = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + return String((await postSealed(s.pairPort, { token: s.token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox })).deviceId) + } + const clean = await pair() + expect(await service.revokeDevice(clean)).toEqual({ local: true, server: 'skipped', relay: 'ok' }) + // The pin is gone, so the phone cannot come back; the UI must not call that a clean removal. + revokeOutcome = async () => ({ persisted: true, killed: false }) + const halfCut = await pair() + expect(await service.revokeDevice(halfCut)).toEqual({ local: true, server: 'skipped', relay: 'cut-unconfirmed' }) + expect(deviceIds()).not.toContain(halfCut) + expect(warn.mock.calls.map((c) => String(c[0]))).toContain('[pairing] could not close the phone relay session') + } finally { + service.stop() + warn.mockRestore() + } + }) + + // revocation.ts: `persisted:false` means the pin may SURVIVE, so the phone's next relay connect would + // be approved with no dialog; the caller MUST retry and MUST NOT show "Removed". The device stays + // listed (in its place) with `local:false`, and Revoke retries the whole revoke. + it('a relay key it could not unpin keeps the device listed, so Revoke can be retried', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const service = createPairingService(relayDeps()) + try { + for (const failed of [ + async () => ({ persisted: false, killed: true }), + async () => ({ persisted: false, killed: false }), + // A revoker that throws said nothing about the pin. + async (): Promise => { + throw new Error('fixture') + } + ]) { + seed() + revokes.length = 0 + const s = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const resp = await postSealed(s.pairPort, { token: s.token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox }) + const id = String(resp.deviceId) + expect(deviceIds()).toEqual(['dev-a', 'dev-b', id]) + // The entry is gone from agent.json BEFORE the unpin is queued (the A07-late late pin must + // not find it), whatever happens after. + let listedDuringUnpin: string[] | null = null + revokeOutcome = async () => { + listedDuringUnpin = deviceIds() + return failed() + } + // Moved to the front, so putting it back must restore its place, not append it. + const reordered = agentJson() + const all = reordered.devices as DeviceEntry[] + writeFileSync(AGENT_JSON, JSON.stringify({ ...reordered, devices: [all[2], all[0], all[1]] }), { mode: 0o600 }) + + expect(await service.revokeDevice(id)).toEqual({ local: false, server: 'skipped', relay: 'unpin-failed' }) + expect(listedDuringUnpin).toEqual(['dev-a', 'dev-b']) + expect(deviceIds()).toEqual([id, 'dev-a', 'dev-b']) + const kept = (agentJson().devices as DeviceEntry[])[0] + expect(kept.relayBoxKey).toBe(phoneBox) // the record a retry needs + expect(authKeys()).not.toContain(`nodeterm-ios-${id}`) // the SSH leg did go + expect(statSync(AGENT_JSON).mode & 0o777).toBe(0o600) + + revokeOutcome = async () => ({ persisted: true, killed: true }) + expect(await service.revokeDevice(id)).toEqual({ local: true, server: 'skipped', relay: 'ok' }) + expect(deviceIds()).toEqual(['dev-a', 'dev-b']) + expect(revokes).toEqual([phoneBox, phoneBox]) + } + } finally { + service.stop() + warn.mockRestore() + } + }) + + it('distinguishes an unconfirmed legacy relay identity from access retained by another pairing', async () => { + const service = createPairingService(relayDeps()) + try { + expect(await service.revokeDevice('dev-a')).toEqual({ local: true, server: 'skipped', relay: 'unconfirmed' }) + const first = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const a = await postSealed(first.pairPort, { token: first.token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox }) + const second = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + await postSealed(second.pairPort, { token: second.token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox }) + expect(await service.revokeDevice(String(a.deviceId))).toEqual({ local: true, server: 'skipped', relay: 'retained' }) + expect(revokes).toEqual([]) + } finally { + service.stop() + } + }) + + it('never pins a key from the PLAINTEXT body (it could be rewritten on the LAN)', async () => { + const service = createPairingService(relayDeps()) + try { + const { token, pairPort } = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const resp = JSON.parse(await post(pairPort, { token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox })) + expect(resp.ok).toBe(true) + expect(resp.relayPinned).toBeUndefined() + expect(pins).toEqual([]) + } finally { + service.stop() + } + }) + + it('ignores a malformed key, and an older phone that sends none still pairs unchanged', async () => { + const service = createPairingService(relayDeps()) + try { + const s1 = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const bad = await postSealed(s1.pairPort, { token: s1.token, publicKey: freshEd25519Line(), boxPublicKey: 'not-a-key' }) + expect(bad.ok).toBe(true) + expect(bad.relayPinned).toBeUndefined() + const s2 = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const old = await postSealed(s2.pairPort, { token: s2.token, publicKey: freshEd25519Line() }) + expect(old.ok).toBe(true) + expect(old.relayPinned).toBeUndefined() + expect(pins).toEqual([]) + } finally { + service.stop() + } + }) + + it('does not pin when the relay leg failed (nothing to connect through yet)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, status: 503, json: async () => ({}) } as unknown as Response) + const service = createPairingService(relayDeps()) + try { + const { token, pairPort } = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const resp = await postSealed(pairPort, { token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox }) + expect(resp.relayPinned).toBeUndefined() + expect(pins).toEqual([]) + } finally { + service.stop() + } + }) +}) + +describe('a paired phone whose relay key was recorded, not pinned, is approved on its first relay handshake (audit A07-late)', () => { + // A phone paired while remote access was off gets no relay leg, so pairing records its sealed box + // key without pinning it. Turning remote access on later lets the phone adopt the relay over SSH + // (relay-advertise.ts), and its first relay connect used to raise the SAS dialog, typically at an + // empty desk. The standing host now asks `approvePairedRelayKey` first. + const hostKeys = genKeyPair() + const phoneBox = publicKeyToB64(genKeyPair().publicKey) + const stranger = publicKeyToB64(genKeyPair().publicKey) + const pins: string[] = [] + const latePins: string[] = [] + const revokes: string[] = [] + let phoneAccess = false + let pinThrows = false + // The app asks `stillPaired` INSIDE the pin store's queue (pinApprovedDeviceIf, approved-devices.test). + const latePin: NonNullable = async (pub, stillPaired) => { + if (!(await stillPaired())) return false + latePins.push(pub) + return true + } + // `null` = not wired (a default parameter would replace `undefined`). + const relayDeps = (late: PairingRelayDeps['pinRelayKeyIfPaired'] | null = latePin): PairingRelayDeps => ({ + getSettings: () => ({ phoneAccessEnabled: phoneAccess }) as unknown as Settings, + getEntitlement: () => null, + loadHostKeyPair: async () => hostKeys, + relayEndpoint: 'wss://relay.example/ws', + apiBase: 'https://api.example', + relayAllowed: () => true, + pinRelayKey: async (pub: string) => { + if (pinThrows) throw new Error('fixture') + pins.push(pub) + }, + revokeRelayKey: async (pub: string) => (revokes.push(pub), { persisted: true, killed: true }), + ...(late ? { pinRelayKeyIfPaired: late } : {}) + }) + const pairSealed = async (service: ReturnType, body: Record) => { + const { token, pairPort } = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + return postSealedTo(hostKeys, pairPort, { token, publicKey: freshEd25519Line(), ...body }) + } + + beforeEach(() => { + pins.length = 0 + latePins.length = 0 + revokes.length = 0 + phoneAccess = false + pinThrows = false + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ deviceToken: 'device-token', hostId: 'host-id', exp: 0 }) + } as unknown as Response) + }) + + it('remote access off: records the key, pins nothing at the scan, promises approval, and pins it on the handshake', async () => { + const service = createPairingService(relayDeps()) + try { + const resp = await pairSealed(service, { deviceId: 'p1', boxPublicKey: phoneBox }) + expect(resp.relayDeviceToken).toBeUndefined() + expect(resp.relayPinned).toBeUndefined() + expect(resp.relayApproved).toBe(true) + expect(pins).toEqual([]) // a LAN-only phone is not a relay phone until it connects through it + const entry = ((agentJson().devices as DeviceEntry[]) ?? []).find((d) => d.id === resp.deviceId)! + expect(entry.relayBoxKey).toBe(phoneBox) + + expect(await service.approvePairedRelayKey(phoneBox)).toBe(true) + expect(latePins).toEqual([phoneBox]) + // A key no pairing recorded is the SAS dialog's, as before. + expect(await service.approvePairedRelayKey(stranger)).toBe(false) + expect(latePins).toEqual([phoneBox]) + } finally { + service.stop() + } + }) + + it('a revoked pairing no longer approves its key, unless another pairing of the same phone is listed', async () => { + const service = createPairingService(relayDeps()) + try { + const a = await pairSealed(service, { boxPublicKey: phoneBox }) + const b = await pairSealed(service, { boxPublicKey: phoneBox }) + await service.revokeDevice(String(a.deviceId)) + expect(await service.approvePairedRelayKey(phoneBox)).toBe(true) + await service.revokeDevice(String(b.deviceId)) + expect(revokes).toEqual([phoneBox]) // the A07-revoke unpin + cut, once the last pairing went + latePins.length = 0 + expect(await service.approvePairedRelayKey(phoneBox)).toBe(false) + expect(latePins).toEqual([]) + } finally { + service.stop() + } + }) + + it('a relay leg whose pin failed at the scan is still approved on the handshake', async () => { + phoneAccess = true + pinThrows = true + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const service = createPairingService(relayDeps()) + try { + const resp = await pairSealed(service, { boxPublicKey: phoneBox }) + expect(resp.relayDeviceToken).toBe('device-token') + expect(resp.relayPinned).toBeUndefined() + expect(resp.relayApproved).toBe(true) + expect(await service.approvePairedRelayKey(phoneBox)).toBe(true) + } finally { + service.stop() + warn.mockRestore() + } + }) + + it('a pinned pairing answers both fields', async () => { + phoneAccess = true + const service = createPairingService(relayDeps()) + try { + const resp = await pairSealed(service, { boxPublicKey: phoneBox }) + expect(resp.relayPinned).toBe(true) + expect(resp.relayApproved).toBe(true) + expect(pins).toEqual([phoneBox]) + } finally { + service.stop() + } + }) + + it('a key from the PLAINTEXT body is never recorded, promised or approved', async () => { + const service = createPairingService(relayDeps()) + try { + const { token, pairPort } = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const resp = JSON.parse(await post(pairPort, { token, publicKey: freshEd25519Line(), boxPublicKey: phoneBox })) + expect(resp.ok).toBe(true) + expect(resp.relayApproved).toBeUndefined() + expect(await service.approvePairedRelayKey(phoneBox)).toBe(false) + expect(latePins).toEqual([]) + } finally { + service.stop() + } + }) + + it('without the late pin wired, the answer promises nothing the standing host would not keep', async () => { + const service = createPairingService(relayDeps(null)) + try { + const resp = await pairSealed(service, { boxPublicKey: phoneBox }) + expect(resp.relayApproved).toBeUndefined() + expect(await service.approvePairedRelayKey(phoneBox)).toBe(false) + } finally { + service.stop() + } + }) + + it('a key no pairing recorded is answered without the pin store (review of A07-late)', async () => { + // The standing host asks on EVERY unpinned handshake, and most of those keys were never recorded + // (iOS phones, pre-A07 pairings, strangers). Their "no" must not queue behind, read or write the + // pin store before the dialog: the browse socket could close inside that window. + const asked: string[] = [] + const service = createPairingService(relayDeps(async (pub, stillPaired) => (asked.push(pub), latePin(pub, stillPaired)))) + try { + expect(await service.approvePairedRelayKey(stranger)).toBe(false) + expect(asked).toEqual([]) + await pairSealed(service, { boxPublicKey: phoneBox }) + expect(await service.approvePairedRelayKey(stranger)).toBe(false) + expect(asked).toEqual([]) + expect(await service.approvePairedRelayKey(phoneBox)).toBe(true) + expect(asked).toEqual([phoneBox]) // a recorded key is still asked again inside the queue + } finally { + service.stop() + } + }) + + it('a malformed key never reaches the pin store, and a failed check falls back to the dialog', async () => { + const asked: string[] = [] + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const service = createPairingService( + relayDeps(async (pub) => { + asked.push(pub) + throw new Error('fixture') + }) + ) + try { + expect(await service.approvePairedRelayKey('not-a-key')).toBe(false) + expect(await service.approvePairedRelayKey('')).toBe(false) + expect(asked).toEqual([]) + await pairSealed(service, { boxPublicKey: phoneBox }) // recorded, so the pin store is asked + expect(await service.approvePairedRelayKey(phoneBox)).toBe(false) + expect(asked).toEqual([phoneBox]) + expect(warn.mock.calls.map((c) => String(c[0]))).toContain('[pairing] could not pin a paired phone relay key:') + } finally { + service.stop() + warn.mockRestore() + } + }) +}) + +describe('the sealed answer carries this computer’s SSH host key fingerprints (audit A49-anchor)', () => { + // The phone's first SSH connect used to pin whichever key answered at the paired address (once that + // server had accepted the phone's key). The `/pair` exchange is already bound to the host key on + // this screen, so the answer it seals now names the computer's own SSH host keys, and the first + // connect is checked against them. + const hostKeys = genKeyPair() + // GitHub's published ed25519 host key and the fingerprint OpenSSH prints for it (an outside vector; + // ssh-host-keys.test.ts has the format cases). + const PUB = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl root@box' + const FP = 'SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU' + const relayDeps = (): PairingRelayDeps => ({ + getSettings: () => ({ phoneAccessEnabled: true }) as unknown as Settings, + getEntitlement: () => null, + loadHostKeyPair: async () => hostKeys, + relayEndpoint: 'wss://relay.example/ws', + apiBase: 'https://api.example', + relayAllowed: () => true + }) + let keyDir = '' + const withKeys = (): string => { + mkdirSync(keyDir, { recursive: true }) + writeFileSync(path.join(keyDir, 'ssh_host_ed25519_key.pub'), `${PUB}\n`) + return keyDir + } + + beforeEach(() => { + keyDir = path.join(HOME, `etc-ssh-${randomBytes(4).toString('hex')}`) + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ deviceToken: 'device-token', hostId: 'host-id', exp: 0 }) + } as unknown as Response) + }) + + it('a sealed answer names them; the QR does not', async () => { + const service = createPairingService(relayDeps(), { sshHostKeyDirs: [withKeys()] }) + try { + const { payload } = await service.start(() => {}) + expect(payload).not.toContain(FP) + expect(payload).not.toContain('sshHostKey') + const { token, pairPort } = JSON.parse(payload) as { token: string; pairPort: number } + const resp = await postSealedTo(hostKeys, pairPort, { token, publicKey: freshEd25519Line(), deviceId: 'p1' }) + expect(resp.ok).toBe(true) + expect(resp.sshHostKeyFingerprints).toEqual([FP]) + } finally { + service.stop() + } + }) + + it('a plaintext answer never does (it could be rewritten on the LAN)', async () => { + const service = createPairingService(relayDeps(), { sshHostKeyDirs: [withKeys()] }) + try { + const { token, pairPort } = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const text = await post(pairPort, { token, publicKey: freshEd25519Line(), deviceId: 'p2' }) + const resp = JSON.parse(text) as Record + expect(resp.ok).toBe(true) + expect('sshHostKeyFingerprints' in resp).toBe(false) + expect(text).not.toContain(FP) + } finally { + service.stop() + } + }) + + it('keys it cannot read are left out, and the pairing goes on without the field', async () => { + const junkDir = path.join(HOME, `etc-ssh-junk-${randomBytes(4).toString('hex')}`) + mkdirSync(junkDir, { recursive: true }) + writeFileSync(path.join(junkDir, 'ssh_host_x_key.pub'), 'not a key\n') + // No such directory, then one whose only "key" is not a key. + for (const dir of [keyDir, junkDir]) { + const service = createPairingService(relayDeps(), { sshHostKeyDirs: [dir] }) + try { + const { token, pairPort } = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const resp = await postSealedTo(hostKeys, pairPort, { token, publicKey: freshEd25519Line() }) + expect(resp.ok).toBe(true) + expect('sshHostKeyFingerprints' in resp).toBe(false) + } finally { + service.stop() + } + } + }) + + it('a Windows desktop sends none: the phone has no SSH leg there', async () => { + const service = createPairingService(relayDeps(), { + sshHostKeyDirs: [withKeys()], + platform: 'win32', + detectKeyFile: async () => 'administrators', + defaultRouteAddress: async () => null + }) + try { + const { token, pairPort } = JSON.parse((await service.start(() => {})).payload) as { token: string; pairPort: number } + const resp = await postSealedTo(hostKeys, pairPort, { token, publicKey: freshEd25519Line(), deviceId: 'pw' }) + expect(resp.relayDeviceToken).toBe('device-token') + expect('sshHostKeyFingerprints' in resp).toBe(false) + } finally { + service.stop() + } + }) +}) + describe('pairing names the phone platform-neutrally', () => { const nameOf = (id: string): string | undefined => ((agentJson().devices as DeviceEntry[] | undefined) ?? []).find((d) => d.id === id)?.name diff --git a/src/main/pairing-service.ts b/src/main/pairing-service.ts index ebdf88fa33..566bb0236d 100644 --- a/src/main/pairing-service.ts +++ b/src/main/pairing-service.ts @@ -10,6 +10,9 @@ // Windows is relay-only: no key is installed (see `directSsh` in createPairingService), the QR // says `"ssh":false`, and a pairing whose relay mint fails pairs nothing. // +// A sealed answer also carries this computer's SSH host key fingerprints (`ssh-host-keys.ts`), so the +// phone checks its first SSH connect against them instead of trusting whichever key answers there. +// // Pure bits (payload build, key validation, LAN-IPv4 pick) live in `pairing-core.ts` so they're // unit-tested without spinning up a server. @@ -22,14 +25,15 @@ import { execFile } from 'child_process' import { promisify } from 'util' import os from 'os' import path from 'path' +import { pairingNetworkChoices, pairingNetworkIPv4, type PairingInterfaces, type PairingNetworkChoice } from '../shared/pairing-network' import { buildPairingPayload, filterAuthorizedKeys, + holdsPairedRelayKey, + isValidBoxPublicKeyB64, isValidEd25519PublicKey, normalizeAuthorizedKeysLine, normalizeDeviceName, - pickLanIPv4, - pickPairingIPv4, readDevices, removeDevice, rewriteKeyComment, @@ -40,11 +44,16 @@ import { type RelayPairingBlock } from './pairing-core' import type { DeviceRevokeResult, DeviceRevokeServerOutcome, Settings } from '../shared/types' -import { renameAtomic, tempNameFor } from '../core/fs-atomic' +import { renameAtomic, renameAtomicSync, tempNameFor } from '../core/fs-atomic' +import { legacyPairingEntryVersion, readLegacyPairingKey, readOwnedRegularFile } from './pairing-legacy-key' +import type { LegacyRelayPairings, LegacyRelayPairingTarget, LegacyRelayPairingInspection } from './remote/relay-pairing-proof' +import { RELAY_PAIRING_KEY, RELAY_PAIRING_UUID, type RelayPairingProofResult } from '../shared/relay-pairing-proof' import { publicKeyToB64, deriveSharedKey, encrypt, decrypt, type KeyPair } from './remote/e2ee' import { hostIdFromPublicKeyB64 } from './remote/relay-id' +import type { RevokeResult } from './remote/revocation' import { getDeviceId } from '../core/device-id' import { administratorsKeysPath, detectWindowsKeyFile, type WindowsKeyFile } from './windows-ssh-keys' +import { readSshHostKeyFingerprints, SSH_HOST_KEY_DIRS } from './ssh-host-keys' const execFileAsync = promisify(execFile) @@ -65,6 +74,29 @@ export interface PairingRelayDeps { apiBase: string /** Dev gate: never hit the prod relay/API from an unpackaged build (mirrors host-service). */ relayAllowed(): boolean + /** + * Pin a phone's relay (box) public key on the standing host, as its SAS approval would (audit + * A07). Optional: absent ⇒ the phone approves on its first relay connect, as before. + */ + pinRelayKey?(boxPublicKeyB64: string): Promise + /** + * Undo [pinRelayKey] when the device is revoked AND cut every relay session that key has open at + * that moment: an unpin alone refuses only the NEXT handshake, so a phone connected while it is + * forgotten would keep its terminals (audit A07-revoke). index.ts wires the revoker + * `remote:revoke-peer` uses (peer-revoker.ts), which reports both steps rather than throwing. + */ + revokeRelayKey?(boxPublicKeyB64: string): Promise + /** + * Pin `boxPublicKeyB64` on the standing host IF `stillPaired()` answers true at the moment the pin + * is written, and say whether it is pinned (A07-late). index.ts runs `stillPaired` INSIDE the + * approved-devices queue (`pinApprovedDeviceIf`), which is what keeps a revoke's unpin from being + * overtaken: a revoke removes the agent.json entry before it queues its unpin, so a check that runs + * after the unpin can no longer find the entry (a revoke lists it again only when that unpin could + * not be written, while the key is still pinned). Optional: absent ⇒ a phone whose key was recorded + * without being pinned approves on its first relay connect, as before, and the `/pair` answer does + * not promise otherwise. + */ + pinRelayKeyIfPaired?(boxPublicKeyB64: string, stillPaired: () => Promise): Promise } interface RelayDeviceResponse { @@ -238,6 +270,10 @@ export type PairingDone = { } export interface PairingService { + /** Exact retained SSH proof repairs a legacy association; never grants approval or changes pins. */ + readonly legacyRelayPairings: LegacyRelayPairings + /** Read this computer's current IPv4 adapters, without guessing phone reachability. */ + listNetworks(): PairingNetworkChoice[] /** Begin pairing; resolves once the listener is up. `onDone` fires exactly once later. */ start(onDone: (result: PairingDone) => void): Promise /** Cancel an in-flight pairing (idempotent). Does NOT fire onDone. */ @@ -245,15 +281,34 @@ export interface PairingService { /** All paired devices (token stripped) from ~/.nodeterm/agent.json. */ listDevices(): Promise /** - * Revoke a device: revoke its relay trust (unpin + cut live relay sessions), drop its agent.json - * entry, delete its authorized_keys line, AND take its Pro - * entitlement back on the relay backend. The two legs are reported separately — see - * `DeviceRevokeResult`; this never throws, because a failure the caller cannot see is exactly - * how the server leg went missing in the first place. + * Revoke a device: drop its agent.json entry, delete its authorized_keys line, unpin its relay key + * and close the relay sessions it has open (A07-revoke), AND take its Pro entitlement back on the + * relay backend. The legs are reported separately — see `DeviceRevokeResult`; this never throws, + * because a failure the caller cannot see is exactly how the server leg went missing in the first + * place. A legacy pairing with no relay-key association reports that remote revocation remains + * unconfirmed; another pairing that still authorizes the key reports retained access. */ revokeDevice(id: string): Promise /** Live re-probe of sshd (127.0.0.1:22), for the Remote Login warning's auto-clear. */ probeSsh(): Promise + /** + * The standing host met a relay key it has not pinned (A07-late). Pin it when a pairing still + * listed in agent.json recorded that key from its sealed `/pair` body, and answer whether it did: + * `true` ⇒ approve the handshake silently, `false` ⇒ raise the SAS dialog as before. Never throws. + * + * Why the record is enough: the key arrived sealed to this desktop's host key, behind the one-time + * token shown on its screen — the same authority that installed the phone's SSH key with full shell + * access. Pairing pins it at once only when it minted a relay leg; a phone paired while remote + * access was off adopts the relay later over SSH (relay-advertise.ts), and its first relay connect + * used to raise the dialog, typically with nobody at the desk. Pinning here rather than at the scan + * keeps "pinned" meaning "a phone that uses the relay", which host-mode push keys on. + * + * So the pairing is what authorizes the key: forgetting a phone means revoking its device + * (`revokeDevice` drops the entry, then unpins). Unpinning the key alone (the `remote:revoke-peer` + * channel, which no screen calls today) lasts only until its next handshake while that pairing is + * still listed. + */ + approvePairedRelayKey(boxPublicKeyB64: string): Promise } /** ~/.nodeterm holds the host-agent config (agent.json). Created 0700 if missing. */ @@ -419,6 +474,10 @@ function readBody(req: IncomingMessage): Promise { export interface PairingServiceOptions { /** Defaults to `process.platform`. */ platform?: NodeJS.Platform + /** Defaults to the OS interface list, re-read for every start/list. */ + interfaces?: () => PairingInterfaces + /** The saved adapter NAME, read at use. Empty or absent means automatic. */ + getPairingInterface?: () => string /** Windows key-file detection (explanation only). Defaults to the real probe. */ detectKeyFile?: () => Promise /** Windows' machine-wide administrators key file, swept on revoke. */ @@ -427,6 +486,9 @@ export interface PairingServiceOptions { defaultRouteAddress?: () => Promise /** Defaults to PAIR_TIMEOUT_MS. */ timeoutMs?: number + /** Where sshd's host keys (and `sshd_config`) are read from for the sealed answer's + * `sshHostKeyFingerprints` (audit A49-anchor). Defaults to `SSH_HOST_KEY_DIRS`. */ + sshHostKeyDirs?: readonly string[] /** * Revoke the phone's RELAY trust on Remove: unpin its box key from the phone store and close its * live relay sessions (peer-revoke.ts `revokeAllPhones`). Without it a removed phone keeps its @@ -441,6 +503,7 @@ export function createPairingService( options: PairingServiceOptions = {} ): PairingService { const platform = options.platform ?? process.platform + const interfaces = options.interfaces ?? os.networkInterfaces // Windows: no SSH key, relay only. Everything the phone sends over SSH is POSIX sh + tmux // (nodeterm-ios HostCommands / TmuxBinary / TerminalTransport), Windows OpenSSH hands out // cmd.exe, and sessions live in the session host — so a key sshd accepts only pins the phone to @@ -498,6 +561,59 @@ export function createPairingService( await fs.chmod(AGENT_JSON_PATH, 0o600).catch(() => {}) } + /** Only migration uses this staged publication: the final admission and rename are synchronous. + * The queue fences this service's pair/revoke writers, not another process or manual file edits. */ + async function writeLegacyAssociation(obj: Record, admitted: () => boolean): Promise { + const tmp = tempNameFor(AGENT_JSON_PATH) + try { + await fs.writeFile(tmp, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 }) + await fs.chmod(tmp, 0o600) + if (!admitted()) return false + // No await between the current approved-session fence and publication. + renameAtomicSync(tmp, AGENT_JSON_PATH) + return true + } finally { await fs.rm(tmp, { force: true }).catch(() => {}) } + } + + const inspectLegacy = (obj: Record, pairingId: string, peerKey: string): LegacyRelayPairingInspection => { + if (!RELAY_PAIRING_UUID.test(pairingId) || !RELAY_PAIRING_KEY.test(peerKey)) return { status: 'unprovable' } + const matches = readDevices(obj).filter(entry => entry?.id === pairingId) + if (!matches.length) return { status: 'gone' } + if (matches.length !== 1) return { status: 'unprovable' } + const entry = matches[0] + if (entry.relayBoxKey) return { status: entry.relayBoxKey === peerKey ? 'associated' : 'conflict' } + if (entry.ssh === false || typeof entry.token !== 'string' || !entry.token || !Number.isFinite(entry.pairedAt)) return { status: 'unprovable' } + const sshPublicKeyB64 = readLegacyPairingKey(AUTH_KEYS_PATH, pairingId) + return sshPublicKeyB64 ? { status: 'eligible', target: { pairingId, sshPublicKeyB64, + entryVersion: legacyPairingEntryVersion(entry) } } : { status: 'unprovable' } + } + const legacyRelayPairings: LegacyRelayPairings = { + inspect: (pairingId, peerKey) => serialize(async () => inspectLegacy(await readAgentJson(), pairingId, peerKey)), + associate: (target: LegacyRelayPairingTarget, peerKey, current) => serialize(async (): Promise => { + if (!current()) return { status: 'expired' } + const obj = await readAgentJson() + if (!current()) return { status: 'expired' } + const found = inspectLegacy(obj, target.pairingId, peerKey) + if (found.status !== 'eligible') return found + if (found.target.entryVersion !== target.entryVersion || found.target.sshPublicKeyB64 !== target.sshPublicKeyB64) return { status: 'unprovable' } + let refused: RelayPairingProofResult = { status: 'expired' } + const published = await writeLegacyAssociation({ ...obj, devices: readDevices(obj).map(entry => + entry.id === target.pairingId ? { ...entry, relayBoxKey: peerKey } : entry) }, () => { + // Re-attest captured entry/key after staging. Refuse replacement/manual edits instead of + // publishing a stale whole-file snapshot. This final bounded read never escapes the host. + const snapshot = readOwnedRegularFile(AGENT_JSON_PATH, 4 * 1024 * 1024) + if (!snapshot) { refused = { status: 'unprovable' }; return false } + const fresh = JSON.parse(snapshot.toString('utf8')) as Record + const next = inspectLegacy(fresh, target.pairingId, peerKey) + if (next.status !== 'eligible') { refused = next; return false } + if (next.target.entryVersion !== target.entryVersion || next.target.sshPublicKeyB64 !== target.sshPublicKeyB64 || + JSON.stringify(fresh) !== JSON.stringify(obj)) { refused = { status: 'unprovable' }; return false } + return current() + }) + return published ? { status: 'associated' } : refused + }) + } + /** Persist a device into agent.json, preserving all other fields the host agent wrote. */ async function persistDevice(entry: DeviceEntry): Promise { const obj = await readAgentJson() @@ -592,15 +708,18 @@ export function createPairingService( cleanup() onDoneCb = onDone - const host = directSsh - ? pickLanIPv4(os.networkInterfaces()) - : pickPairingIPv4( - os.networkInterfaces(), - await (options.defaultRouteAddress ?? defaultRouteIPv4)().catch(() => null) - ) + // Only Windows uses its existing OS route hint. An explicit adapter always wins, and is + // re-read after the asynchronous hint so a settings/DHCP change is not cached into the QR. + const routeAddress = !directSsh && !options.getPairingInterface?.() + ? await (options.defaultRouteAddress ?? defaultRouteIPv4)().catch(() => null) + : null + const preferred = options.getPairingInterface?.() ?? '' + const host = pairingNetworkIPv4(interfaces(), preferred, routeAddress) if (!host) { onDoneCb = null - throw new Error("Couldn't detect a LAN IP address — connect to Wi-Fi and try again.") + throw new Error(preferred + ? `The selected pairing network “${preferred}” has no usable IPv4 address. Reconnect it or choose another network in Settings → Phone.` + : "Couldn't detect a LAN IPv4 address — connect to a network and try again.") } const token = randomBytes(24).toString('base64url') const user = os.userInfo().username @@ -702,6 +821,7 @@ export function createPairingService( deviceName?: unknown deviceId?: unknown priorDeviceToken?: unknown + boxPublicKey?: unknown } let sealed: Uint8Array | null = null // the shared key, set only on the encrypted path if (typeof outer.epk === 'string') { @@ -736,6 +856,7 @@ export function createPairingService( deviceName?: unknown deviceId?: unknown priorDeviceToken?: unknown + boxPublicKey?: unknown } } if (body.token !== token) { @@ -758,6 +879,23 @@ export function createPairingService( // unchanged, and the mint still reads this same value. const phoneDeviceId = typeof body.deviceId === 'string' && body.deviceId.trim() ? body.deviceId.trim() : deviceId + // The phone's relay identity (audit A07). Taken ONLY from the sealed body: that body is + // sealed to the QR's host key and authorized by the one-time token on this screen — the same + // authority that installs an SSH key with full shell access — so pinning it here is the + // human's approval, given by scanning. A plaintext body could be rewritten on the LAN. + const relayBoxKey = sealed && isValidBoxPublicKeyB64(body.boxPublicKey) ? body.boxPublicKey : undefined + let relayPinned = false + const pinRelay = async (): Promise => { + if (!relayBoxKey || !relayFields.relayDeviceToken || !relayDeps?.pinRelayKey) return + try { + await relayDeps.pinRelayKey(relayBoxKey) + relayPinned = true + } catch (err) { + // Not fatal: the key is recorded on the device entry, so the standing host pins it on the + // phone's first relay handshake instead (`approvePairedRelayKey`, A07-late). + console.warn('[pairing] could not pin the phone relay key:', err) + } + } let relayFields: { relay?: RelayPairingBlock; relayDeviceToken?: string } = {} const mint = async (): Promise => { if (!relayCtx) return @@ -788,12 +926,14 @@ export function createPairingService( token: agentToken, pairedAt: Date.now(), lastSeenAt: 0, - relayDeviceId: phoneDeviceId + relayDeviceId: phoneDeviceId, + ...(relayBoxKey ? { relayBoxKey } : {}) }) }) // Provision relay access for the phone when enabled. Any failure ⇒ LAN-only: we never // fail the pairing over a relay hiccup (the phone still got its SSH key installed). await mint() + await pinRelay() } else { // Relay-only (Windows). The relay IS the connection, so there is nothing to fall back // to: no relay leg ⇒ nothing is paired, and both ends are told so. Minted BEFORE the @@ -826,13 +966,41 @@ export function createPairingService( pairedAt: Date.now(), lastSeenAt: 0, relayDeviceId: phoneDeviceId, + ...(relayBoxKey ? { relayBoxKey } : {}), ssh: false }) ) + await pinRelay() } // Build the response exactly as before; wrap it in the box only when the request was // encrypted (same shared key), so the relay device token never crosses the LAN in cleartext. - const responseObj = { ok: true, deviceId, agentToken, ...relayFields } + // `relayPinned` (additive, A07): the key was pinned just now. + // `relayApproved` (additive, A07-late): this desktop serves the phone's relay key without the + // SAS dialog, so the phone may treat the relay as approved. True when the key was pinned just + // now, and also when it was only recorded (no relay leg yet, or the pin failed): the standing + // host pins a recorded key on the phone's first relay handshake (`approvePairedRelayKey`), as + // after a late adoption. Not pinned at the scan without a relay leg, so a LAN-only pairing + // does not count as a relay phone (host-mode push keys on the pin store). + const relayApproved = relayPinned || (!!relayBoxKey && !!relayDeps?.pinRelayKeyIfPaired) + // `sshHostKeyFingerprints` (additive, A49-anchor): this computer's SSH host key fingerprints, + // so the phone's first SSH connect must present one of them instead of pinning whichever key + // answers at the paired address. In the SEALED answer only, which is bound to the host key on + // this screen; a plaintext answer could be rewritten on the LAN, and the QR stays as it is. + // Not on Windows, where the phone has no SSH leg. Unreadable keys ⇒ the field is left out and + // the phone keeps its pin-after-authentication, as with an older desktop. + const sshHostKeyFingerprints = + sealed && directSsh + ? await readSshHostKeyFingerprints(options.sshHostKeyDirs ?? SSH_HOST_KEY_DIRS).catch(() => []) + : [] + const responseObj = { + ok: true, + deviceId, + agentToken, + ...relayFields, + ...(relayPinned ? { relayPinned: true } : {}), + ...(relayApproved ? { relayApproved: true } : {}), + ...(sshHostKeyFingerprints.length ? { sshHostKeyFingerprints } : {}) + } if (sealed) { const respBox = encrypt( Uint8Array.from(Buffer.from(JSON.stringify(responseObj), 'utf8')), @@ -883,8 +1051,8 @@ export function createPairingService( // One unit: agent.json and authorized_keys must not be revoked half-way by an interleaving writer. // - // Relay trust goes first (see inside), then authorized_keys, and the order is load-bearing on - // partial failure. authorized_keys is full + // Legacy unassociated relay trust goes first; a recorded key is revoked after removing its + // pairing below, so a late paired pin cannot resurrect it. authorized_keys is full // shell access; agent.json holds the host-agent bearer token and the device the UI lists. If the // second step fails, revoking the SSH key first leaves the BIGGER capability already gone and the // device still listed — visible to its owner, with the Revoke button still there to finish the @@ -894,34 +1062,88 @@ export function createPairingService( // The relay device id is read BEFORE either write and kept even when the local leg fails: the // entitlement is what authorizes the server leg, not the local write's success, and a phone the // user asked to remove should stop being minted Pro either way. + // + // The relay leg runs LAST, inside the same unit, and only after agent.json no longer lists the + // device: the standing host's late pin (`approvePairedRelayKey`, A07-late) pins a key a listed + // pairing recorded, so an unpin queued while the entry is still there could be undone by it. If + // the unpin cannot be written, the pin may survive, and the phone's next relay connect would be + // approved with no dialog (revocation.ts: the caller MUST retry and MUST NOT show "Removed"). So + // the entry is put back where it was and `local` is false: the device stays listed, which is the + // same partial state as an agent.json write that failed, and Revoke retries the whole thing. The + // key is still pinned in that case, so listing the device again gives the late pin nothing new. const revokeDevice = async (id: string): Promise => { - const { local, relayId, found } = await serialize(async () => { - const entry = readDevices(await readAgentJson()).find((d) => d.id === id) + const { local, relayId, found, relay } = await serialize(async () => { + const before = readDevices(await readAgentJson()) + const entry = before.find((d) => d.id === id) const relayId = entry?.relayDeviceId + const boxKey = entry?.relayBoxKey const found = !!entry + let devices: DeviceEntry[] + let legacyRelayRevoked = false try { - // Relay trust FIRST, for the same reason as the order below: it is shell access (a pinned - // box key is auto-admitted by the standing host with the full phone vocabulary), so it goes - // before the device leaves the list. If it fails, the device stays listed and the owner can - // retry. It runs only for a device we actually know, so a stale id cannot unpin anything. - if (found && options.revokePhoneRelayTrust) { + // A legacy entry has no recorded relay key to revoke precisely: use the phone-only + // fallback before removing it. A recorded key follows the exact-key path below, leaving + // other paired phones and another pairing of this same key authorized. A stale id does + // not invoke either revoker. + if (found && !boxKey && options.revokePhoneRelayTrust) { const relay = await options.revokePhoneRelayTrust() if (!relay.persisted || !relay.killed) { throw new Error(`relay trust revoke incomplete (persisted=${relay.persisted}, killed=${relay.killed})`) } + legacyRelayRevoked = true } await removeAuthorizedKeysForDevice(id) await removeAdministratorsKeysForDevice(id) const obj = await readAgentJson() - const devices = removeDevice(readDevices(obj), id) + devices = removeDevice(readDevices(obj), id) await writeAgentJson({ ...obj, devices }) - return { local: true, relayId, found } } catch (err) { // Reported, not thrown: `local:false` is what the UI turns into "try again", and the // server leg below is still worth running. The detail belongs in the log. console.warn('[pairing] local revoke failed:', err) - return { local: false, relayId, found } + return { local: false, relayId, found, relay: undefined } + } + // The relay pin made at pairing goes with the device, and so do the relay sessions that key + // has open — unless another pairing of the same phone (a re-pair keeps its box key) is still + // listed: that pairing still authorizes the phone, its pin and its session. + if (!entry) { + return { local: true, relayId, found, relay: undefined } + } + // Older pairings did not record a relay identity. Without the phone-only fallback, do not + // guess from the approved-key store + // or call a different peer's revoker: local removal cannot confirm remote access is gone. + if (!boxKey) return { local: true, relayId, found, relay: legacyRelayRevoked ? 'ok' as const : 'unconfirmed' as const } + if (devices.some((d) => d.relayBoxKey === boxKey)) { + return { local: true, relayId, found, relay: 'retained' as const } + } + if (!relayDeps?.revokeRelayKey) { + return { local: true, relayId, found, relay: 'unconfirmed' as const } } + // Unpins the key, then closes the relay sessions it has open right now (peer-revoker.ts). + // A revoker that throws said nothing about the pin, so it counts as an unpin that failed. + const outcome = await relayDeps.revokeRelayKey(boxKey).catch((err) => { + console.warn('[pairing] could not revoke the phone relay key:', err) + return null + }) + if (outcome?.persisted) { + if (outcome.killed) return { local: true, relayId, found, relay: 'ok' as const } + // The pin is gone, so the phone cannot come back; a session whose close threw may still be + // half open, and the UI says so rather than "Removed". + console.warn('[pairing] could not close the phone relay session') + return { local: true, relayId, found, relay: 'cut-unconfirmed' as const } + } + console.warn('[pairing] could not unpin the phone relay key; keeping the device listed so Revoke can be retried') + try { + const obj = await readAgentJson() + const now = readDevices(obj).filter((d) => d.id !== id) + const at = Math.min(Math.max(before.findIndex((d) => d.id === id), 0), now.length) + await writeAgentJson({ ...obj, devices: [...now.slice(0, at), entry, ...now.slice(at)] }) + } catch (err) { + // Both writes failed: the device is gone and its pin may not be. Still `local:false`, so + // the UI does not report a clean removal. + console.warn('[pairing] could not keep the device listed after the failed unpin:', err) + } + return { local: false, relayId, found, relay: 'unpin-failed' as const } }) // A device paired before `relayDeviceId` was recorded still falls back to OUR id — which is // not a guess. `id` is the per-pairing `randomUUID()` above, and when the phone sent no id of @@ -951,8 +1173,29 @@ export function createPairingService( relayDeps?.getEntitlement() ?? null ) : 'skipped' - return { local, server } + return relay ? { local, server, relay } : { local, server } + } + + const approvePairedRelayKey = async (boxPublicKeyB64: string): Promise => { + if (!isValidBoxPublicKeyB64(boxPublicKeyB64) || !relayDeps?.pinRelayKeyIfPaired) return false + // Read fresh each time, not through `serialize`: agent.json is published by atomic rename, so + // this sees the file either before or after a revoke's write, and the ordering argument on + // `pinRelayKeyIfPaired` covers both. A missing or malformed file lists no device ⇒ no pin. + const stillPaired = async (): Promise => + holdsPairedRelayKey(readDevices(await readAgentJson()), boxPublicKeyB64) + // Asked once BEFORE the pin store's queue, so the commonest unpinned handshake (a key no + // pairing recorded: an iOS phone, a pairing older than A07, a stranger) is answered with one + // file read and never waits behind, or writes, the pin store (review of A07-late). A "no" + // pins nothing, so it cannot undo a revoke; only a "yes" has to be asked again in the queue. + try { + if (!(await stillPaired())) return false + return await relayDeps.pinRelayKeyIfPaired(boxPublicKeyB64, stillPaired) + } catch (err) { + // The SAS dialog is the fallback, exactly as before this existed. + console.warn('[pairing] could not pin a paired phone relay key:', err) + return false + } } - return { start, stop, listDevices, revokeDevice, probeSsh } + return { start, stop, listNetworks: () => pairingNetworkChoices(interfaces()), listDevices, revokeDevice, probeSsh, approvePairedRelayKey, legacyRelayPairings } } diff --git a/src/main/peer-revoke-wiring.test.ts b/src/main/peer-revoke-wiring.test.ts new file mode 100644 index 0000000000..8cea24ed53 --- /dev/null +++ b/src/main/peer-revoke-wiring.test.ts @@ -0,0 +1,46 @@ +// Forgetting a paired phone must reach the revoker that CUTS its live relay session (audit +// A07-revoke), and nothing but this file's shell can make that true. +// +// `PairingRelayDeps.revokeRelayKey` is optional and typed as "a key in, a result out", so wiring it +// to a bare unpin (what it was before: `updateApprovedDevices(unpinDevice)`) is well-typed, passes +// every unit test, and ships the hole again: the pin goes, the phone that is connected right then +// keeps its terminals. standing-host.test.ts proves the revoker cuts; this pins that main hands the +// pairing service THAT revoker. Same remedy as remote-end-wiring.test.ts: pinned at source level. +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'fs' +import path from 'path' + +const SRC = readFileSync(path.join(__dirname, 'index.ts'), 'utf8').replace(/\r\n/g, '\n') + +/** The text of the `createPairingService({ … })` call: up to its closing `})` at that indentation. */ +function pairingServiceCall(): string { + const start = SRC.indexOf('createPairingService({') + expect(start, 'createPairingService is not called — this guard is looking at the wrong file').toBeGreaterThan(-1) + const end = SRC.indexOf('\n })', start) + expect(end).toBeGreaterThan(start) + return SRC.slice(start, end) +} + +describe('main hands the phone revoke the session-cutting revoker', () => { + it('uses the registered-host revoke primitive for both paths', () => { + expect(SRC).toContain("import { revokeAllPhones, revokePeerKey } from './remote/peer-revoke'") + // A second, hand-assembled revoker is how one revoke path ends up cutting less than the other. + expect(SRC).not.toContain('createRevoker(') + }) + + it('the pairing service revokes a phone relay key through it', () => { + const call = pairingServiceCall() + expect(call).toContain("return revokePeerKey(pub, ['phone'])") + expect(call).toMatch(/revokeRelayKey: \(pub\) => \{\s+rememberRevokedStandingPhone\(pub\)\s+return revokePeerKey/) + expect(pairingServiceCall()).toContain('revokePhoneRelayTrust: revokeAllPhones') + }) + + it('`remote:revoke-peer` goes through the same revoker', () => { + const handler = SRC.indexOf('ipcMain.handle(IPC.remoteRevokePeer') + expect(handler).toBeGreaterThan(-1) + const call = SRC.slice(handler, SRC.indexOf('\n })', handler)) + expect(call).toContain('return revokePeerKey(pub, PIN_ROLES)') + expect(call.indexOf('rememberRevokedStandingPhone(pub)')).toBeGreaterThan(-1) + expect(call.indexOf('rememberRevokedStandingPhone(pub)')).toBeLessThan(call.indexOf('revokePeerKey(pub, PIN_ROLES)')) + }) +}) diff --git a/src/main/relay-attach-wiring.test.ts b/src/main/relay-attach-wiring.test.ts index 883c520242..381ac0fc60 100644 --- a/src/main/relay-attach-wiring.test.ts +++ b/src/main/relay-attach-wiring.test.ts @@ -16,8 +16,15 @@ describe('relay attach routing is wired in the desktop shell', () => { }) it('the relay host attaches only through core’s decision, never the bare local attach', () => { const src = read('main/remote/host-service.ts') - expect(src).toMatch(/pty\.prepareRelayAttach\(nodeId, \{ cols, rows \}, \{ projectId: projectHint \}\)/) - expect(src).not.toMatch(/pty\.attachDetached\(/) + expect(src).toMatch(/pty\.prepareRelayAttach\(nodeId, \{ cols, rows \}, \{\s*projectId: str\(p\.projectId\), \.\.\.\(create \? \{ create \} : \{\}\)\s*\}\)/) + const start = src.indexOf('if (pty.prepareRelayAttach) {') + const end = src.indexOf('\n } else {', start) + expect(start).toBeGreaterThan(-1) + expect(end).toBeGreaterThan(start) + expect(src.slice(start, end)).not.toMatch(/pty\.attachDetached\(/) + // Older manager fixtures retain their bounded compatibility branch; current core always + // routes through the saved local/remote placement decision above. + expect(src).toContain('const sessionId = await prep.attach(sinks)') }) it('a Team Access guest’s create in an SSH project is forced requireRemote', () => { expect(read('main/index.ts')).toMatch( diff --git a/src/main/remote-atomic-write.ts b/src/main/remote-atomic-write.ts index 3fc4faee20..5c585d6717 100644 --- a/src/main/remote-atomic-write.ts +++ b/src/main/remote-atomic-write.ts @@ -32,6 +32,8 @@ export interface RemoteAtomicWriteOptions { mode?: RemoteFileMode /** False when the caller already created and permissioned the parent directory. */ makeParent?: boolean + /** Trusted caller-owned shell check after stdin is written, immediately before publication. */ + beforePublish?: string /** An editor may legitimately save an empty file. Nothing this app GENERATES is ever empty, so * an empty body is refused unless the caller says otherwise. */ allowEmpty?: boolean @@ -113,8 +115,9 @@ export function remoteAtomicWrite( // with `-` could look like an option, and that one gets `./`. const chmodTarget = temporaryPath.startsWith('-') ? quoteRemotePath(`./${temporaryPath}`) : temporary const protect = options.mode ? ` && chmod ${options.mode} ${chmodTarget}` : '' + const check = options.beforePublish ? ` && ( ${options.beforePublish} )` : '' const command = - `${prefix}${parent}{ cat > ${temporary}${complete}${protect} && mv -f -- ${temporary} ${target}; ` + + `${prefix}${parent}{ cat > ${temporary}${complete}${protect}${check} && mv -f -- ${temporary} ${target}; ` + `nt_status=$?; ` + `rm -f -- ${temporary}; exit "$nt_status"; }` return { command, stdin: body, temporaryPath } diff --git a/src/main/remote-ssh/ssh-agent.ts b/src/main/remote-ssh/ssh-agent.ts index b5c319cfee..849bb2ba08 100644 --- a/src/main/remote-ssh/ssh-agent.ts +++ b/src/main/remote-ssh/ssh-agent.ts @@ -73,8 +73,9 @@ interface AgentChild { } /** NT_MULTI sandboxes carry their identity in NT_USER_DATA; every other instance derives it from - * its real userData dir. The second half matters because a bare `npm run dev` (app name - * "node-terminal") and the installed app ("nodeterm") have different userData dirs, so the + * its real userData dir. The second half matters because a bare `npm run dev` (userData + * `/node-terminal`) and another instance with its own userData (an NT_MULTI sandbox, a + * Server Edition data dir) have different userData dirs, so the * single-instance lock does not stop them running side by side - under one fixed key the second * instance's start() rmSync's the first one's LIVE socket and its quit unlinks it again, leaving * that app silently agentless (a prompt per reconnect) for the rest of its run. Guarded require: diff --git a/src/main/remote-ssh/ssh-project.acks.test.ts b/src/main/remote-ssh/ssh-project.acks.test.ts new file mode 100644 index 0000000000..c81ca628b4 --- /dev/null +++ b/src/main/remote-ssh/ssh-project.acks.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest' +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp/nodeterm-test' }, + ipcMain: { handle: () => undefined, on: () => undefined } +})) + +import { SshProjectManager } from './ssh-project' +import { REMOTE_ACK_SWEEP_CMD } from '../../core/ack-sweep' + +function fixture(ownership?: (projectId: string) => string[]) { + const calls: { args: string[]; stdin?: string }[] = [] + let replies: string[] = [] + const run = async (args: string[], stdin?: string) => { + if (args.at(-1) === REMOTE_ACK_SWEEP_CMD) { + calls.push({ args, stdin }) + return { code: 0, stdout: replies.shift() ?? '' } + } + return { code: 0, stdout: '' } + } + const manager = new SshProjectManager({ + userDataDir: '/ud', + spawnMaster: () => ({ kill: vi.fn(), on: vi.fn() }), + run, + runScp: async () => ({ code: 0 }), + getHook: () => ({ port: 1, token: 't', version: '1' }), + onStatus: () => {}, + nodeIdsForProject: ownership + }) + return { manager, calls, respond: (...stdout: string[]) => { replies = stdout } } +} + +const host = { host: 'host-one', user: 'me' } + +describe('remote read-ack ownership', () => { + it('unions all projects on one host before dedup and excludes foreign returned ids', async () => { + const ownership: Record = { + p1: ['own-a', 'shared'], p2: ['own-b', 'shared'], p3: ['other-host'] + } + const f = fixture((projectId) => ownership[projectId] ?? []) + await f.manager.connect('p1', host) + await f.manager.connect('p2', host) + await f.manager.connect('p3', { host: 'host-two', user: 'me' }) + f.respond('own-b\nforeign\nshared\nshared\n', 'other-host\nown-a\n') + expect(await f.manager.sweepRemoteAcks()).toEqual(['own-b', 'shared', 'other-host']) + expect(f.calls).toHaveLength(2) + expect(f.calls[0].stdin).toBe('own-a\nshared\nown-b\n') + expect(f.calls[1].stdin).toBe('other-host\n') + }) + + it('does not start a consuming command when ownership is unavailable', async () => { + const f = fixture() + await f.manager.connect('p1', host) + expect(await f.manager.sweepRemoteAcks()).toEqual([]) + expect(f.calls).toEqual([]) + }) + + it('learns and loses project ownership on subsequent sweeps without retaining a stale allowlist', async () => { + const owned = new Map() + const f = fixture((id) => owned.get(id) ?? []) + await f.manager.connect('p1', host) + await f.manager.connect('p2', host) + expect(await f.manager.sweepRemoteAcks()).toEqual([]) + owned.set('p1', ['later']) + owned.set('p2', ['removed']) + f.respond('later\nremoved\n') + expect(await f.manager.sweepRemoteAcks()).toEqual(['later', 'removed']) + await f.manager.disconnect('p2') + f.respond('removed\nlater\n') + expect(await f.manager.sweepRemoteAcks()).toEqual(['later']) + expect(f.calls.map((call) => call.stdin)).toEqual(['later\nremoved\n', 'later\n']) + }) + + it('keeps unknown project files while still consuming known ownership on the same host', async () => { + const f = fixture((id) => { + if (id === 'unknown') throw new Error('canvas not loaded') + return ['known', '../foreign', 'bad\nline'] + }) + await f.manager.connect('unknown', host) + await f.manager.connect('known', host) + f.respond('unknown\nknown\n') + expect(await f.manager.sweepRemoteAcks()).toEqual(['known']) + expect(f.calls[0].stdin).toBe('known\n') + }) +}) diff --git a/src/main/remote-ssh/ssh-project.test.ts b/src/main/remote-ssh/ssh-project.test.ts index 0bf65358ca..47ca352131 100644 --- a/src/main/remote-ssh/ssh-project.test.ts +++ b/src/main/remote-ssh/ssh-project.test.ts @@ -139,12 +139,62 @@ describe('SshProjectManager', () => { for (let i = 0; i < commands.length; i++) { expect(commands[i]).toContain('umask 077') expect(commands[i]).toContain('.nodeterm/pending') + // The hold is checked in the SAME remote command (A06). + expect(commands[i]).toContain(`[ -f ~/'${'/.nodeterm/pending/node-1-2.json'.slice(1)}' ] || exit 3;`) expect(commands[i]).toContain(`mv -f -- ${temps[i]} ~/'${'/.nodeterm/pending/node-1-2.answer'.slice(1)}'`) expect(commands[i]).toContain(`rm -f -- ${temps[i]}`) } expect(calls.map((call) => call[1])).toEqual(['allow', 'deny']) // decisions stay on stdin }) + it('answerPending tells a hold that ended (exit 3) from a failed write', async () => { + const { mgr, run } = makeMgr() + await mgr.connect('p1', conn) + run.mockImplementationOnce(async () => ({ code: 3, stdout: '' })) + expect(await mgr.answerPending('p1', 'node-1-2', 'allow')).toBe('gone') + run.mockImplementationOnce(async () => ({ code: 255, stdout: '' })) + expect(await mgr.answerPending('p1', 'node-1-2', 'allow')).toBe('failed') + run.mockImplementationOnce(async () => { + throw new Error('spawn failed') + }) + expect(await mgr.answerPending('p1', 'node-1-2', 'allow')).toBe('failed') + expect(await mgr.answerPending('p9', 'node-1-2', 'allow')).toBe('failed') + run.mockImplementationOnce(async () => ({ code: 3, stdout: '' })) + expect(await mgr.writePendingAnswer('p1', 'node-1-2', 'allow')).toBe(false) + }) + + it.skipIf(process.platform === 'win32')( + 'the generated answer command, run under a real /bin/sh, answers a live hold and refuses an ended one', + async () => { + // POSIX-only: it runs the command the HOST would run, which is always sh. + const { execFileSync } = await import('child_process') + const { mgr, run } = makeMgr() + await mgr.connect('p1', conn) + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'nt-pend-')) + const pending = path.join(home, '.nodeterm', 'pending') + await fs.mkdir(pending, { recursive: true }) + const exec = (command: string, stdin: string): number => { + try { + execFileSync('/bin/sh', ['-c', command], { input: stdin, env: { HOME: home, PATH: '/usr/bin:/bin' } }) + return 0 + } catch (e) { + return (e as { status?: number }).status ?? 1 + } + } + run.mockImplementation(async (args: string[], stdin?: string) => ({ code: exec(args.at(-1)!, stdin ?? ''), stdout: '' })) + try { + expect(await mgr.answerPending('p1', 'late-1', 'allow')).toBe('gone') + expect(await fs.readdir(pending)).toEqual([]) + writeFileSync(path.join(pending, 'live-1.json'), '{}') + expect(await mgr.answerPending('p1', 'live-1', 'deny')).toBe('sent') + expect(await fs.readFile(path.join(pending, 'live-1.answer'), 'utf8')).toBe('deny') + expect((await fs.readdir(pending)).sort()).toEqual(['live-1.answer', 'live-1.json']) + } finally { + await fs.rm(home, { recursive: true, force: true }) + } + } + ) + it('pushAgentStatus gives overlapping pushes separate temps and preserves private permissions', async () => { const { mgr, run } = makeMgr() await mgr.connect('p1', conn) @@ -3268,6 +3318,115 @@ describe('SshProjectManager — atomic remote import (Task 6.2, Property 2 + 11) }) }) +describe('original-derived structured hook replies over an SSH project', () => { + const request = { hook_event_name: 'PermissionRequest', tool_name: 'Bash', permission_suggestions: [ + { type: 'addRules', behavior: 'allow', destination: 'localSettings', rules: [{ toolName: 'Bash', ruleContent: 'npm test' }] } + ] } + it('rejects a foreign node ticket or an unavailable connection before executing anything', async () => { + const { mgr, run } = makeMgr() + expect(await mgr.answerPendingHook('none', 'node', 'node-1-1', { kind: 'allow-always', suggestionIndex: 0 })).toBe('failed') + await mgr.connect('p1', conn) + const before = run.mock.calls.length + expect(await mgr.answerPendingHook('p1', 'other', 'node-1-1', { kind: 'allow-always', suggestionIndex: 0 })).toBe('failed') + expect(run.mock.calls).toHaveLength(before) + }) + it('keeps derived hook JSON off argv and rejects an unsupported suggested update', async () => { + const { mgr, run } = makeMgr() + await mgr.connect('p1', conn) + run.mockImplementationOnce(async () => ({ code: 0, stdout: `123 12\n${JSON.stringify(request)}` })) + run.mockImplementationOnce(async () => ({ code: 0, stdout: '' })) + expect(await mgr.answerPendingHook('p1', 'node', 'node-1-1', { kind: 'allow-always', suggestionIndex: 0 })).toBe('sent') + const write = run.mock.calls.at(-1)! + expect(write[1]).toContain('nodeterm-hook-reply-v2\n') + expect(write[1]).toContain('updatedPermissions') + expect(write[0].join(' ')).not.toContain('npm test') + expect(write[0].join(' ')).toContain('cksum') + const before = run.mock.calls.length + run.mockImplementationOnce(async () => ({ code: 0, stdout: `123 12\n${JSON.stringify(request)}` })) + expect(await mgr.answerPendingHook('p1', 'node', 'node-1-1', { kind: 'allow-always', suggestionIndex: 1 })).toBe('failed') + expect(run.mock.calls).toHaveLength(before + 1) + }) + it.skipIf(process.platform === 'win32')('refuses a hold deleted or replaced during a suspended stdin write and cleans its exact temp', async () => { + const { spawn, spawnSync } = await import('node:child_process') + const { openSync, closeSync, existsSync } = await import('node:fs') + const { mgr, run } = makeMgr() + await mgr.connect('p1', conn) + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'nt-hook-suspended-')) + const pending = path.join(home, '.nodeterm/pending') + await fs.mkdir(pending, { recursive: true }) + const held = path.join(pending, 'node-1-1.json') + let change: 'replace' | 'remove' = 'remove' + run.mockImplementation(async (args: string[], stdin?: string) => { + const options = { env: { HOME: home, PATH: '/usr/bin:/bin' } } + if (stdin === undefined) { + const child = spawnSync('/bin/sh', ['-c', args.at(-1)!], { ...options, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', timeout: 5000 }) + if (child.error) throw child.error + return { code: child.status ?? 1, stdout: child.stdout } + } + const child = spawn('/bin/sh', ['-c', args.at(-1)!], options) + let stdout = ''; child.stdout.setEncoding('utf8'); child.stdout.on('data', text => { stdout += text }) + child.stdin.on('error', () => {}) + const finished = new Promise((resolve, reject) => { child.once('error', reject); child.once('close', code => resolve(code ?? 1)) }) + child.stdin.write(stdin.slice(0, 16)) + const deadline = Date.now() + 5000 + while (!(await fs.readdir(pending)).some(name => name.endsWith('.tmp'))) { + if (Date.now() >= deadline) { child.kill(); throw new Error('writer did not open its private temp') } + await new Promise(resolve => setTimeout(resolve, 5)) + } + if (change === 'remove') await fs.unlink(held) + else writeFileSync(held, JSON.stringify({ ...request, tool_name: 'Write' })) + child.stdin.end(stdin.slice(16)) + return { code: await finished, stdout } + }) + try { + for (const variant of ['remove', 'replace'] as const) { + change = variant; writeFileSync(held, JSON.stringify(request)) + expect(await mgr.answerPendingHook('p1', 'node', 'node-1-1', { kind: 'allow-always', suggestionIndex: 0 })).toBe(variant === 'remove' ? 'gone' : 'failed') + expect(existsSync(path.join(pending, 'node-1-1.answer'))).toBe(false) + expect((await fs.readdir(pending)).filter(name => name.endsWith('.tmp'))).toEqual([]) + } + } finally { await fs.rm(home, { recursive: true, force: true }) } + }) + it.skipIf(process.platform === 'win32')('executes actual bounded read and stdin write commands and refuses a request changed or removed between them', async () => { + const { spawnSync } = await import('node:child_process') + const { openSync, closeSync, readFileSync, existsSync } = await import('node:fs') + const { mgr, run } = makeMgr() + await mgr.connect('p1', conn) + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'nt-hook-remote-')) + const pending = path.join(home, '.nodeterm/pending') + await fs.mkdir(pending, { recursive: true }) + const held = path.join(pending, 'node-1-1.json') + const answer = path.join(pending, 'node-1-1.answer') + let changeAfterRead: 'replace' | 'remove' | undefined + run.mockImplementation(async (args: string[], stdin?: string) => { + const input = path.join(home, 'stdin') + writeFileSync(input, stdin ?? '') + const fd = openSync(input, 'r') + const child = spawnSync('/bin/sh', ['-c', args.at(-1)!], { stdio: [fd, 'pipe', 'pipe'], encoding: 'utf8', timeout: 5000, env: { HOME: home, PATH: '/usr/bin:/bin' } }) + closeSync(fd) + if (child.error) throw child.error + if (stdin === undefined && changeAfterRead) { + if (changeAfterRead === 'remove') await fs.unlink(held) + else writeFileSync(held, JSON.stringify({ ...request, tool_name: 'Write' })) + } + return { code: child.status ?? 1, stdout: child.stdout } + }) + try { + writeFileSync(held, JSON.stringify(request)) + expect(await mgr.answerPendingHook('p1', 'node', 'node-1-1', { kind: 'allow-always', suggestionIndex: 0 })).toBe('sent') + const output = JSON.parse(readFileSync(answer, 'utf8').substring(readFileSync(answer, 'utf8').indexOf('\n') + 1)) + expect(output.hookSpecificOutput.decision.updatedPermissions).toEqual(request.permission_suggestions) + expect((await fs.stat(answer)).mode & 0o777).toBe(0o600) + await fs.unlink(answer) + for (const change of ['replace', 'remove'] as const) { + writeFileSync(held, JSON.stringify(request)); changeAfterRead = change + expect(await mgr.answerPendingHook('p1', 'node', 'node-1-1', { kind: 'allow-always', suggestionIndex: 0 })).toBe(change === 'remove' ? 'gone' : 'failed') + expect(existsSync(answer)).toBe(false) + } + } finally { await fs.rm(home, { recursive: true, force: true }) } + }) +}) + // From codex-cli 0.157.0 a plain Codex TUI joins ONE auto-started app-server per CODEX_HOME that // keeps the first pane's NODETERM_* env; on an SSH host that means every later remote Codex node // runs as the first one. The fix is `--no-daemon`, which may ride a REMOTE line only when that diff --git a/src/main/remote-ssh/ssh-project.ts b/src/main/remote-ssh/ssh-project.ts index 97a4bd5663..40b865e217 100644 --- a/src/main/remote-ssh/ssh-project.ts +++ b/src/main/remote-ssh/ssh-project.ts @@ -6,6 +6,7 @@ import { createHash, randomUUID } from 'crypto' import { spawn, execFile, execFileSync } from 'child_process' import { app, ipcMain } from 'electron' import { IPC } from '../../shared/ipc' +import type { PtyCreateOptions } from '../../shared/types' import { getMainWindow, sendToMain } from '../main-window' import { parseLsDirs, @@ -38,6 +39,7 @@ import { allowMediaPath } from '../media-protocol' import { remoteAccountConfigDir, isSupportedClaudeVersion } from '../../core/claude-accounts-core' import type { PushGrant } from '../../core/push-grants' import { REMOTE_GRANT_SCAN_CMD, parseRemoteGrants } from '../../core/remote-push-grants' +import { REMOTE_ACK_SWEEP_CMD, remoteAckSweepInput } from '../../core/ack-sweep' import { supportsAutoPermissionMode, supportsFullscreenTui } from '../../shared/agents/config' import { controlPathFor, @@ -80,6 +82,12 @@ import { appSshAgent } from './ssh-agent' import { probeAgentSockToPin } from '../../core/remote-ssh/agent-probe' import { sessionName } from '../../core/tmux-naming' import { remoteAtomicWrite, runRemoteAtomicWrite } from '../remote-atomic-write' +import { pendingBelongsToNode, type PendingAnswerResult } from '../../core/agents/pending-approvals' +import { buildHookReply, HOOK_REQUEST_MAX_BYTES, type HookAnswer } from '../../shared/hook-answers' + +/** The remote answer command's exit status for "the hook's hold already ended" (no request file). + * ssh itself uses 255 and tmux/sh 1/2/126/127, so 3 cannot be mistaken for a transport failure. */ +const PENDING_GONE_EXIT = 3 import { isBoundedAnswerContent, PENDING_REQUEST_MAX_BYTES } from '../../core/agents/permission-decision' import { buildCodexLauncherScript } from '../../core/codex-identity-proxy' import { @@ -1624,6 +1632,26 @@ export class SshProjectManager { return c ? { conn: c.conn, controlPath: c.controlPath, remoteCwd: c.remoteCwd } : undefined } + /** + * The full `sshRemote` a pty needs to run a node of this project on its host — the same fields + * the renderer assembles from a `connected` status. `undefined` while no master exists or while + * the connect's setup chain is still running (a session created before the hook tunnel and the + * remote tmux.conf are in place would have neither — see `connectOnce`). Used by the relay host so + * a phone's attach of an SSH-project node runs on the host, never locally (audit A09). + */ + sshRemoteFor(projectId: string, fallbackRemoteCwd?: string): NonNullable | undefined { + const c = this.conns.get(projectId) + if (!c || this.inFlight.has(projectId)) return undefined + return { + controlPath: c.controlPath, + conn: c.conn, + remoteCwd: c.remoteCwd ?? fallbackRemoteCwd ?? '~', + ...(c.hookEndpointPath ? { hookEndpointPath: c.hookEndpointPath } : {}), + ...(c.tmuxConfPath ? { tmuxConfPath: c.tmuxConfPath } : {}), + ...(c.remoteHome ? { remoteHome: c.remoteHome } : {}) + } + } + /** * Everything a remote SPAWN needs from this connection, for spawns the renderer did not build — * today the relay host's `pty.attach` (core `prepareRelayAttach`). The same facts the renderer @@ -1968,32 +1996,37 @@ export class SshProjectManager { * `~/.nodeterm/acks/.seen` on the host it can reach; for a Mac→SSH node that host is the * REMOTE one, so the desktop must consume them over the ControlMaster, the local-fs sweep never * sees them. One command per connected HOST (deduped by host key, since projects sharing a host - * share `$HOME/.nodeterm/acks`) atomically lists + deletes each `.seen` and prints its nodeId; the + * share `$HOME/.nodeterm/acks`) receives the union of owned node ids on stdin, consumes only those + * files and prints their nodeIds. Files belonging to another desktop stay unread and intact; the * returned ids are fed the SAME `ackDone` + unread-clear path a local ack takes. Best-effort, a * disconnected/failed project simply contributes nothing. The command is fully literal (no - * interpolation), and the returned nodeIds are used only as in-memory map keys (never a path), so - * a compromised host can at worst clear an unread badge / resolve a done card it can guess. + * interpolation), and returned ids must belong to the allowlist sent to that host. */ async sweepRemoteAcks(): Promise { - // List then delete each `~/.nodeterm/acks/*.seen`, printing the basename (nodeId). The `break` on - // a non-existent first match handles the no-glob case (the pattern stays literal when nothing - // matches). Absent dir ⇒ exit 0 (nothing swept). - const cmd = - 'd="$HOME/.nodeterm/acks"; [ -d "$d" ] || exit 0; ' + - 'for f in "$d"/*.seen; do [ -e "$f" ] || break; ' + - 'printf "%s\\n" "$(basename "$f" .seen)"; rm -f "$f"; done' - const seenHosts = new Set() - const out: string[] = [] - for (const c of this.conns.values()) { + // Projects sharing one HOME need one UNION of their owned nodes before host deduplication. + const hosts = new Map }>() + for (const [projectId, c] of this.conns) { const hk = sshHostKey(c.conn) - if (seenHosts.has(hk)) continue - seenHosts.add(hk) + const host = hosts.get(hk) ?? { conn: c, nodeIds: new Set() } + hosts.set(hk, host) try { - const { code, stdout } = await this.r.run(childArgs(c.conn, c.controlPath, cmd)) + for (const id of this.r.nodeIdsForProject?.(projectId) ?? []) host.nodeIds.add(id) + } catch { + // Unknown ownership leaves this project's acks for a later pass. + } + } + const out: string[] = [] + for (const { conn: c, nodeIds } of hosts.values()) { + const input = remoteAckSweepInput(nodeIds) + if (!input) continue + const owned = new Set(input.trimEnd().split('\n')) + try { + const { code, stdout } = await this.r.run( + childArgs(c.conn, c.controlPath, REMOTE_ACK_SWEEP_CMD), input + ) if (code === 0 && stdout) { for (const line of stdout.split('\n')) { - const id = line.trim() - if (id) out.push(id) + if (owned.has(line) && !out.includes(line)) out.push(line) } } } catch { @@ -2037,16 +2070,66 @@ export class SshProjectManager { } /** - * Deterministic hook-reply approvals (docs/hook-reply-approvals.md): write the answer file for a - * held REMOTE permission hook, on the project's host over its ControlMaster (atomic tmp+mv, 0600 - * via umask). The hook is polling `~/.nodeterm/pending/.answer` on that host. - * `content` is a legacy word or a core-built JSON decision; it travels on STDIN, never in the - * remote command line (argv on both ends — a structured answer carries user text), and anything - * the hook script would not print is refused here too (`isBoundedAnswerContent`). `pendingId` is - * validated by the caller (main) before it reaches here; this method also refuses anything but the - * safe charset as defense-in-depth, since it interpolates into a remote shell command. No-ops - * (false) when the project isn't connected or the write fails. + * Deterministic hook-reply approvals (docs/hook-reply-approvals.md): write the one-line answer + * file for a held REMOTE permission hook, on the project's host over its ControlMaster (atomic + * tmp+mv, 0600 via umask). The hook is polling `~/.nodeterm/pending/.answer` on that + * host. `pendingId` is validated by the caller (main) before it reaches here; this method also + * refuses anything but the safe charset as defense-in-depth, since it interpolates into a remote + * shell command. + * + * The request file `.json` is checked IN THE SAME remote command (`exit 3` when it is + * missing): the hook deletes it when its hold ends, and an answer written after that is read by + * nobody — reporting it as delivered is what cleared NEEDS YOU for a prompt still waiting on the + * host (audit A06). `gone` is that case; `failed` is a disconnected project or a failed write. */ + async answerPending( + projectId: string, + pendingId: string, + decision: 'allow' | 'deny' + ): Promise { + const c = this.conns.get(projectId) + if (!c) return 'failed' + if (!/^[A-Za-z0-9_-]+$/.test(pendingId)) return 'failed' + if (decision !== 'allow' && decision !== 'deny') return 'failed' + const dir = c.remoteHome ? `${c.remoteHome}/.nodeterm/pending` : '~/.nodeterm/pending' + const file = `${dir}/${pendingId}.answer` + const request = quoteRemotePath(`${dir}/${pendingId}.json`) + const write = remoteAtomicWrite(file, decision, { restrictPermissions: true, mode: '600', makeParent: false }) + const { code } = await this.r + .run(childArgs(c.conn, c.controlPath, `[ -f ${request} ] || exit ${PENDING_GONE_EXIT}; ${write.command}`), write.stdin) + .catch(() => ({ code: 1, stdout: '' })) + if (code === 0) return 'sent' + return code === PENDING_GONE_EXIT ? 'gone' : 'failed' + } + + /** Structured reply: read the exact remote hold, derive its decision, and recheck before write. */ + async answerPendingHook(projectId: string, nodeId: string, pendingId: string, answer: HookAnswer): Promise { + const c = this.conns.get(projectId) + if (!c || !pendingBelongsToNode(nodeId, pendingId)) return 'failed' + const dir = c.remoteHome ? `${c.remoteHome}/.nodeterm/pending` : '~/.nodeterm/pending' + const request = quoteRemotePath(`${dir}/${pendingId}.json`) + try { + const read = await this.r.run(childArgs(c.conn, c.controlPath, + `[ -f ${request} ] || exit ${PENDING_GONE_EXIT}; [ ! -L ${request} ] || exit 2; ` + + `[ "$(head -c ${HOOK_REQUEST_MAX_BYTES + 1} ${request} | wc -c)" -le ${HOOK_REQUEST_MAX_BYTES} ] || exit 2; nt_c=$(cksum < ${request}) || exit 2; [ "${'${nt_c#* }'}" -le ${HOOK_REQUEST_MAX_BYTES} ] || exit 2; nt_body=$(head -c ${HOOK_REQUEST_MAX_BYTES + 1} ${request}) || exit 2; ` + + `[ "$nt_c" = "$(cksum < ${request})" ] || exit 2; printf '%s\\n%s' "$nt_c" "$nt_body"`)) + if (read.code !== 0) return read.code === PENDING_GONE_EXIT ? 'gone' : 'failed' + const newline = read.stdout.indexOf('\n') + const checksum = read.stdout.slice(0, newline) + const raw = read.stdout.slice(newline + 1) + if (newline < 0 || !/^\d+ \d+$/.test(checksum) || Buffer.byteLength(raw) > HOOK_REQUEST_MAX_BYTES) return 'failed' + const reply = buildHookReply(JSON.parse(raw), answer) + if (!reply || this.conns.get(projectId) !== c) return 'failed' + const stillHeld = `[ -f ${request} ] || exit ${PENDING_GONE_EXIT}; [ ! -L ${request} ] || exit 2; ` + + `[ "$(cksum < ${request})" = ${posixQuote(checksum)} ] || exit 2` + const write = remoteAtomicWrite(`${dir}/${pendingId}.answer`, reply, { restrictPermissions: true, mode: '600', makeParent: false, beforePublish: stillHeld }) + const result = await this.r.run(childArgs(c.conn, c.controlPath, + `[ -f ${request} ] || exit ${PENDING_GONE_EXIT}; [ ! -L ${request} ] || exit 2; ` + + `[ "$(cksum < ${request})" = ${posixQuote(checksum)} ] || exit 2; ${write.command}`), write.stdin) + return result.code === 0 ? 'sent' : result.code === PENDING_GONE_EXIT ? 'gone' : 'failed' + } catch { return 'failed' } + } + async writePendingAnswer( projectId: string, pendingId: string, @@ -2059,8 +2142,12 @@ export class SshProjectManager { const file = `${this.pendingDirFor(c)}/${pendingId}.answer` try { // The hook polls for this file, so it must never see a truncated answer under its name. - const write = remoteAtomicWrite(file, content, { restrictPermissions: true }) - const { code } = await this.r.run(childArgs(c.conn, c.controlPath, write.command), write.stdin) + const request = quoteRemotePath(`${this.pendingDirFor(c)}/${pendingId}.json`) + const held = `[ -f ${request} ] || exit ${PENDING_GONE_EXIT}` + const write = remoteAtomicWrite(file, content, { + restrictPermissions: true, mode: '600', makeParent: false, beforePublish: held + }) + const { code } = await this.r.run(childArgs(c.conn, c.controlPath, `${held}; ${write.command}`), write.stdin) return code === 0 } catch { return false diff --git a/src/main/remote/android-history-fixture.test.ts b/src/main/remote/android-history-fixture.test.ts new file mode 100644 index 0000000000..58336fc7d7 --- /dev/null +++ b/src/main/remote/android-history-fixture.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ChildProcess } from 'node:child_process' +import { SessionHostPty } from '../../core/session-host-pty' +import { composedBackendFixture } from '../../../android/protocol/src/test/interop/composed-backend-fixture' + +type OwnedHost = { + child: ChildProcess + close: () => Promise + exitObserved: boolean +} +const observed = vi.hoisted(() => ({ hosts: [] as OwnedHost[] })) + +// Keep the real bundle, daemon, socket, client and recorder. Only capture this test's owned +// child and let the bundle use its private root instead of the interop runner's prebuilt path. +vi.mock('../../session-host/__fixtures__/composed-host', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, async bootComposedHost(root: string, _bundlePath?: string, mode?: string) { + const host = await actual.bootComposedHost(root, undefined, mode) + const owned: OwnedHost = { child: host.child, close: () => host.close(), exitObserved: false } + host.child.once('exit', () => { owned.exitObserved = true }) + observed.hosts.push(owned) + return host + } } +}) + +describe('Android native history fixture startup cleanup', () => { + it('reaps its admitted real host before rejecting a painter registration failure', async () => { + const root = mkdtempSync(join(tmpdir(), 'nodeterm-android-history-startup-')) + const failure = new Error('explicit painter registration failure') + const baseline = observed.hosts.length + const onData = vi.spyOn(SessionHostPty.prototype, 'onData').mockImplementation(() => { throw failure }) + try { + await expect(composedBackendFixture(root, 'session-host', () => {})).rejects.toBe(failure) + const hosts = observed.hosts.slice(baseline) + expect(hosts).toHaveLength(1) + expect(hosts[0].child.pid).toBeGreaterThan(0) + expect(hosts[0].exitObserved, 'startup must await the actual owned child exit before rejecting').toBe(true) + expect(hosts[0].child.exitCode !== null || hosts[0].child.signalCode !== null).toBe(true) + } finally { + onData.mockRestore() + // Also reap the exact captured child when the cleanup-await mutant fails the assertion. + await Promise.all(observed.hosts.slice(baseline).map((host) => host.close())) + observed.hosts.splice(baseline) + rmSync(root, { recursive: true, force: true }) + } + }, 15_000) +}) diff --git a/src/main/remote/android-interop-fixture.guard.test.ts b/src/main/remote/android-interop-fixture.guard.test.ts new file mode 100644 index 0000000000..90fc6e94f5 --- /dev/null +++ b/src/main/remote/android-interop-fixture.guard.test.ts @@ -0,0 +1,148 @@ +// The Android companion's interop fixture (android/protocol/src/test/interop/host-fixture.ts) runs +// this directory's real host code against the Kotlin client: `connectHostSession` over a local relay +// broker and `createPairingService` for the pairing tests. To do that it implements the interfaces +// host-service.ts takes from its callers — HostPtyManager and the kanban / inbox / nodeActions bridge. +// +// Nothing checked it against them (audit A67). InteropHarness.kt bundles the fixture with esbuild, +// which strips types without checking them; no tsconfig included the file; and the Android workflow's +// path filter then skipped most of src/ (audit A63), so a desktop change could break the fixture's fit +// to an interface without any check noticing until an interop test failed at run time, or never, where +// the broken member is one no test exercises. It passed tsc when the audit tried it, but nothing kept +// it so. +// +// tsconfig.node.json now includes the fixture, so `npm run typecheck` (every CI run, every platform) +// checks it. These two guards keep that true: the file stays in the project, and it hands the desktop +// code nothing through a cast, which would switch the check off for exactly the value it exists for. +// The fixture used to pass its fake pty `as unknown as PtyManager` (HostSessionOptions demanded the +// whole class) and its settings `as never`; the option now names the slice the session uses. + +import { describe, expect, it } from 'vitest' +import { execFileSync } from 'child_process' +import { readdirSync, readFileSync, statSync } from 'fs' +import { join, relative } from 'path' + +const REPO_ROOT = join(__dirname, '..', '..', '..') +const INTEROP_DIR = join(REPO_ROOT, 'android', 'protocol', 'src', 'test', 'interop') + +/** Repo-relative, `/`-separated, whatever the host running Vitest. */ +function repoPath(file: string): string { + return relative(REPO_ROOT, file).replace(/\\/g, '/') +} + +function interopSources(dir: string = INTEROP_DIR, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry) + if (statSync(p).isDirectory()) interopSources(p, out) + else if (/\.ts$/.test(entry)) out.push(p) + } + return out +} + +describe('the Android interop fixture is type-checked against the desktop interfaces (audit A67)', () => { + it('every .ts file under the interop dir is in the project `npm run typecheck` checks', () => { + const sources = interopSources().map(repoPath) + // Not vacuous: a moved or renamed fixture must fail here, not pass over an empty directory. + expect(sources).toEqual( + expect.arrayContaining([ + 'android/protocol/src/test/interop/host-fixture.ts', + 'android/protocol/src/test/interop/electron-stub.ts' + ]) + ) + + // Ask the compiler, not the include globs: an `exclude`, a narrower glob or a moved file all + // show up here. `--listFilesOnly` resolves the project without type-checking it (that is + // `npm run typecheck`'s job), and runs the package's own bin script, so no npm shim is involved. + const tsc = join(REPO_ROOT, 'node_modules', 'typescript', 'bin', 'tsc') + const listed = execFileSync(process.execPath, [tsc, '-p', 'tsconfig.node.json', '--listFilesOnly'], { + cwd: REPO_ROOT, + encoding: 'utf8' + }) + .split('\n') + .map((line) => line.trim().replace(/\\/g, '/')) + .filter(Boolean) + // Matched by suffix: tsc may print a realpath where the checkout sits behind a symlink. + const missing = sources.filter((rel) => !listed.some((abs) => abs.endsWith('/' + rel))) + expect(missing, 'add these to tsconfig.node.json\'s include (see the comment there)').toEqual([]) + }, 60_000) + + it('serves projects.list from the desktop\'s own assembly, never a hand-written blob or mirror (audit A64)', () => { + // The fixture used to serve a blob it wrote by hand, markers and agent-status mirror included, so a + // desktop change to either could not fail an Android test. Now both it and `listProjectsOutput` + // call core's `buildProjectsListBlob`, and the fixture's mirror file comes out of the mirror. + const code = (file: string): string => + readFileSync(file, 'utf8') + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n') + const fixture = code(join(INTEROP_DIR, 'host-fixture.ts')) + const index = code(join(REPO_ROOT, 'src', 'main', 'index.ts')) + for (const [name, src] of [['host-fixture.ts', fixture], ['src/main/index.ts', index]]) { + expect(src, `${name} spells a projects.list marker; import it from src/core/projects-list-blob.ts`).not.toMatch( + /--NT-(PROJECTS|STATUS)-SPLIT--/ + ) + expect(src, `${name} must build the blob with buildProjectsListBlob`).toMatch(/\bbuildProjectsListBlob\(/) + } + expect(fixture).toMatch(/\blistProjects:\s*\(\)\s*=>\s*buildProjectsListBlob\(/) + // A mirror document written by hand carries its version key; the real one comes from the writer. + expect(fixture, 'write agent-status.json with the mirror (recordAgentEvent + flush), not by hand').not.toMatch(/\bv:\s*1\b/) + expect(fixture).toMatch(/\brecordAgentEvent\(/) + }) + + it('serves git.* from the desktop\'s real GitService behind the production jail, never a fake (audit A29)', () => { + // The phone's Source Control is checked against what the git bridge really answers: the same + // class `hostBridge.git` hands both phone hosts, jailed to the same roots production passes (every + // local project folder, `workspaceRoots`). A recording fake here would pass whatever the phone + // parses, and a wider jail would hide a refusal the phone has to show. + const code = (file: string): string => + readFileSync(file, 'utf8') + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n') + const fixture = code(join(INTEROP_DIR, 'host-fixture.ts')) + const index = code(join(REPO_ROOT, 'src', 'main', 'index.ts')) + expect(index).toMatch(/\bconst gitService = new GitService\(\)/) + expect(index).toMatch(/\bgit: gitService\b/) + expect(index).toMatch(/\bworkspaceRoots: \(\) => workspaceStore\.localProjectCwds\(\)/) + expect(fixture).toMatch(/\{ git: new GitService\(\) \}/) + expect(fixture).toMatch(/\bextraRoots: \(\) => store\.localProjectCwds\(\)/) + expect(fixture).toMatch(/from '\.\.\/\.\.\/\.\.\/\.\.\/\.\.\/src\/core\/git-service'/) + }) + + it('serves the `lan` field from the desktop\'s own reporter, never a hand-written one (audit A74-refresh)', () => { + // The phone refreshes the LAN address it dials and the SSH keys it trusts from this field, so the + // Kotlin client must be checked against what the desktop's reporter really sends: the QR's address + // pick and the sealed answer's host-key reader, over the test's interfaces and host-key dir. + const code = (file: string): string => + readFileSync(file, 'utf8') + .replace(/\r\n/g, '\n') + .split('\n') + .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line)) + .join('\n') + const fixture = code(join(INTEROP_DIR, 'host-fixture.ts')) + const index = code(join(REPO_ROOT, 'src', 'main', 'index.ts')) + expect(index).toMatch(/\blanReport: createHostLanReporter\(\{ getPairingInterface: \(\) => settingsStore\.get\(\)\.phonePairingInterface \}\)/) + expect(index).toMatch(/createPairingService\([\s\S]+?\}, \{\s+getPairingInterface: \(\) => settingsStore\.get\(\)\.phonePairingInterface,\s+revokePhoneRelayTrust: revokeAllPhones\s+\}\)/) + expect(fixture).toMatch(/\blanReport: createHostLanReporter\(\{/) + expect(fixture).toMatch(/from '\.\.\/\.\.\/\.\.\/\.\.\/\.\.\/src\/main\/remote\/host-lan-report'/) + expect(fixture, 'the fixture must not read this machine\'s /etc/ssh').toMatch(/\bsshHostKeyDirs: \[process\.env\.FIXTURE_SSH_HOST_KEY_DIR \|\|/) + }) + + it('hands the desktop code nothing through a cast that would switch the type check off', () => { + const CAST = /\bas\s+(?:unknown\s+as|never|any)\b/ + const offenders: string[] = [] + for (const file of interopSources()) { + readFileSync(file, 'utf8') + .split('\n') + .forEach((line, i) => { + if (/^\s*(\/\/|\*|\/\*)/.test(line)) return // comments may name the casts they forbid + if (CAST.test(line)) offenders.push(`${repoPath(file)}:${i + 1}: ${line.trim()}`) + }) + } + expect( + offenders, + 'type the value as the interface the desktop code takes (widen that interface if it demands more than it uses)' + ).toEqual([]) + }) +}) diff --git a/src/main/remote/approved-devices.test.ts b/src/main/remote/approved-devices.test.ts index eb941215dc..9e76d84534 100644 --- a/src/main/remote/approved-devices.test.ts +++ b/src/main/remote/approved-devices.test.ts @@ -22,6 +22,7 @@ import { guestPins, joinedHostPins, phonePins, + pinApprovedDeviceIf, pinFileName, retireLegacyPinFile } from './approved-devices' @@ -35,7 +36,7 @@ vi.mock('electron', () => ({ app: { getPath: () => userData } })) const loadApprovedDevices = (): Promise => phonePins.load() const saveApprovedDevices = (s: ApprovedDevices): Promise => phonePins.save(s) -const updateApprovedDevices = (u: (s: ApprovedDevices) => ApprovedDevices): Promise => phonePins.update(u) +const updateApprovedDevices = (u: (s: ApprovedDevices) => ApprovedDevices | Promise): Promise => phonePins.update(u) describe('approved-devices atomic write', () => { let target: string @@ -152,6 +153,91 @@ describe('approved-devices atomic write', () => { }) }) +describe('pinApprovedDeviceIf: the late pin of a paired phone (audit A07-late)', () => { + beforeEach(() => { + userData = mkdtempSync(path.join(tmpdir(), 'nt-approved-')) + }) + + afterEach(() => { + vi.restoreAllMocks() + rmSync(userData, { recursive: true, force: true }) + }) + + it('pins only when the check says so, and says whether the key is pinned', async () => { + await saveApprovedDevices({ pubkeys: ['other'] }) + expect(await pinApprovedDeviceIf('phone', async () => false)).toBe(false) + expect(await loadApprovedDevices()).toEqual({ pubkeys: ['other'] }) + expect(await pinApprovedDeviceIf('phone', async () => true)).toBe(true) + expect(await loadApprovedDevices()).toEqual({ pubkeys: ['other', 'phone'] }) + }) + + it('an already pinned key is true without asking, and an empty key is never pinned', async () => { + await saveApprovedDevices({ pubkeys: ['phone'] }) + const asked = vi.fn(async () => false) + expect(await pinApprovedDeviceIf('phone', asked)).toBe(true) + expect(asked).not.toHaveBeenCalled() + expect(await pinApprovedDeviceIf('', async () => true)).toBe(false) + expect(await loadApprovedDevices()).toEqual({ pubkeys: ['phone'] }) + }) + + it('a check that throws rejects and changes nothing; the queue goes on', async () => { + await saveApprovedDevices({ pubkeys: ['other'] }) + await expect( + pinApprovedDeviceIf('phone', async () => { + throw new Error('fixture') + }) + ).rejects.toThrow('fixture') + await updateApprovedDevices((s) => pinDevice(s, 'next')) + expect(await loadApprovedDevices()).toEqual({ pubkeys: ['other', 'next'] }) + }) + + it('a revoke racing the check never leaves the key pinned', async () => { + // A revoke removes the phone's agent.json entry (`paired = false` here) and only then queues its + // unpin. The check reads `paired` when it starts and answers when `release` fires, after the + // revoke has queued its unpin. Asked inside the queue, the pin lands before the unpin, which + // removes it. Asked before queueing (the race this exists to close), the pin would be queued + // after the unpin, from an answer the revoke had already withdrawn, and survive it. + let paired = true + let release!: () => void + const answered = new Promise((r) => (release = r)) + let checkStarted!: () => void + const started = new Promise((r) => (checkStarted = r)) + const pin = pinApprovedDeviceIf('phone', async () => { + const answer = paired + checkStarted() + await answered + return answer + }) + await started + paired = false // the revoke's agent.json write… + const unpin = updateApprovedDevices((s) => unpinDevice(s, 'phone')) // …then its queued unpin + release() + await Promise.all([pin, unpin]) + expect(await loadApprovedDevices()).toEqual({ pubkeys: [] }) + }) + + it('an update that changes nothing writes nothing (review of A07-late)', async () => { + // The standing host asks on every unpinned handshake; a "no", or a key already pinned, must not + // cost a write (and its rename retries) before the phone is answered, nor fail on one. + const target = path.join(userData, 'remote-approved-devices.json') + expect(await pinApprovedDeviceIf('phone', async () => false)).toBe(false) + await expect(fs.stat(target)).rejects.toMatchObject({ code: 'ENOENT' }) // still no file at all + await saveApprovedDevices({ pubkeys: ['phone'] }) + const writes = vi.spyOn(fs, 'writeFile') + const renames = vi.spyOn(fs, 'rename').mockRejectedValue(Object.assign(new Error('fixture'), { code: 'EACCES' })) + expect(await pinApprovedDeviceIf('phone', async () => true)).toBe(true) + expect(await pinApprovedDeviceIf('other', async () => false)).toBe(false) + await updateApprovedDevices((s) => unpinDevice(s, 'never-pinned')) + expect(writes).not.toHaveBeenCalled() + expect(renames).not.toHaveBeenCalled() + // A real change still writes, and still reports a failed write. + await expect(pinApprovedDeviceIf('other', async () => true)).rejects.toThrow('fixture') + expect(writes).toHaveBeenCalled() + vi.restoreAllMocks() + expect(await loadApprovedDevices()).toEqual({ pubkeys: ['phone'] }) + }) +}) + describe('pin stores are split by role', () => { beforeEach(() => { userData = mkdtempSync(path.join(tmpdir(), 'nt-approved-')) diff --git a/src/main/remote/approved-devices.ts b/src/main/remote/approved-devices.ts index 92d89007fd..f43a72a7e8 100644 --- a/src/main/remote/approved-devices.ts +++ b/src/main/remote/approved-devices.ts @@ -27,7 +27,9 @@ import { app } from 'electron' import { writeFileAtomic } from '../../core/fs-atomic' import { emptyApprovedDevices, + isPinned, parseApprovedDevices, + pinDevice, type ApprovedDevices } from './approved-devices-core' @@ -64,7 +66,7 @@ export interface PinStoreIO { * pins, and an approval racing a revoke can resurrect the removed key from an obsolete snapshot. * This is an in-process queue, not a cross-process trust-store lock. */ - update(change: (store: ApprovedDevices) => ApprovedDevices): Promise + update(change: (store: ApprovedDevices) => ApprovedDevices | Promise): Promise } function filePath(name: string): string { @@ -85,11 +87,13 @@ function createPinStore(role: PinRole): PinStoreIO { await writeFileAtomic(file(), JSON.stringify(store), { mode: 0o600 }) } let tail: Promise = Promise.resolve() - const update = (change: (store: ApprovedDevices) => ApprovedDevices): Promise => { + const update = (change: (store: ApprovedDevices) => ApprovedDevices | Promise): Promise => { const next = tail.then(async () => { - await save(change(await load())) + const store = await load() + const updated = await change(store) + if (updated !== store) await save(updated) }) - tail = next.catch(() => {}) // one failed save must not poison later attempts + tail = next.catch(() => {}) // hold the whole async read/modify/write; one failed save must not poison later attempts return next } return { load, save, update } @@ -106,14 +110,21 @@ export function pinStore(role: PinRole): PinStoreIO { return role === 'phone' ? phonePins : role === 'guest' ? guestPins : joinedHostPins } +// Compatibility names are PHONE-only and share phonePins' serialized queue, including late pins. +export const loadApprovedDevices = (): Promise => phonePins.load() +export const saveApprovedDevices = (store: ApprovedDevices): Promise => phonePins.save(store) +export const updateApprovedDevices = ( + update: (store: ApprovedDevices) => ApprovedDevices | Promise +): Promise => phonePins.update(update) + /** * Retire the pre-split `remote-approved-devices.json`. Call once at boot, BEFORE the standing host * starts. Returns how many keys the legacy file held (0 when it was absent), for the log line. * * NOTHING in it is carried into the phone store, and that is deliberate (fail-closed). The file * mixed three roles with no tag, and nothing on this machine can tell them apart: the phone's relay - * box key is never sent at pairing (agent.json records the SSH key and the backend device id, not - * the box key), so a legacy key cannot be matched to a paired phone. Carrying the file over as-is + * box key was not recorded by older pairings, and the mixed file has no role metadata even when + * a newer pairing records its box key. Carrying the mixed file over as-is * would keep exactly the hole this split closes (a joined host or a revoked guest admitted as a * phone). The cost of dropping it is one SAS comparison per phone, on its next relay connect. * @@ -133,3 +144,28 @@ export async function retireLegacyPinFile(): Promise { await fs.rm(legacy, { force: true }) return count } + +/** + * Pin `pubkeyB64` only if `allowed()` still answers true, asked INSIDE the queue, and say whether it + * is pinned afterwards (A07-late: the standing host pinning a paired phone's recorded relay key). + * + * The question is asked in the queue, not before it, because the answer can be withdrawn: revoking + * a paired phone removes its agent.json entry and only THEN queues its unpin. Asked before queueing, + * a "still paired" read could land just before that removal while its pin landed just after the + * unpin, resurrecting the key the revoke had just removed. Asked inside, it runs either before the + * unpin (which then removes the pin) or after the removal (which it then sees). Already pinned ⇒ + * true without asking. A rejected `allowed()` or save rejects; the caller falls back to the dialog. + */ +export async function pinApprovedDeviceIf(pubkeyB64: string, allowed: () => Promise): Promise { + let pinned = false + await updateApprovedDevices(async (store) => { + if (isPinned(store, pubkeyB64)) { + pinned = true + return store + } + if (!pubkeyB64 || !(await allowed())) return store + pinned = true + return pinDevice(store, pubkeyB64) + }) + return pinned +} diff --git a/src/main/remote/host-chat-wiring.test.ts b/src/main/remote/host-chat-wiring.test.ts index 8533dc1144..185af7fc1a 100644 --- a/src/main/remote/host-chat-wiring.test.ts +++ b/src/main/remote/host-chat-wiring.test.ts @@ -15,12 +15,12 @@ describe('phone chat verbs are wired end to end', () => { expect(src).toMatch(/\n\s{4}chat: createHostChat\(\{/) // Both phone hosts receive that one bridge. expect(src).toMatch(/initRemoteHost\(win, ptyManager, listProjectsOutput, hostBridge\)/) - expect(src).toMatch(/initStandingHost\(.*, hostBridge\)/) + expect(src).toMatch(/initStandingHost\(win, ptyManager, \(\) => settingsStore\.get\(\), listProjectsOutput, hostBridge, \{/) }) it('both phone hosts forward bridge.chat into the session, and the session into the handlers', () => { expect(read('main/remote/host-service.ts')).toMatch(/chat: bridge\.chat,/) expect(read('main/remote/standing-host.ts')).toMatch(/chat: bridge\.chat,/) - expect(read('main/remote/host-service.ts')).toMatch(/opts\.kanban,\n\s*opts\.chat\n\s*\)/) + expect(read('main/remote/host-service.ts')).toMatch(/opts\.kanban,\n\s*opts\.inbox,\n\s*opts\.remoteNodes,\n\s*opts\.newSessions,\n\s*opts\.lanReport,\n\s*opts\.chat\n\s*\)/) }) it('the remote transcript leg resolves an UNMOUNTED SSH node by its project', () => { const src = read('main/index.ts') diff --git a/src/main/remote/host-composed-input.test.ts b/src/main/remote/host-composed-input.test.ts new file mode 100644 index 0000000000..7cb04eb0c7 --- /dev/null +++ b/src/main/remote/host-composed-input.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest' +import { createHostHandlers, type HostFsOps, type HostPtyManager, type HostRelaySocket } from './host-service' +import { COMPOSED_INPUT_UNSUPPORTED, type ComposedInput, type ComposedInputResult } from '../../shared/composed-input' +import { OP } from './framing' + +const flush = async (): Promise => { await new Promise((resolve) => setImmediate(resolve)) } +function fixture(submitComposed?: HostPtyManager['submitComposed']) { + const responses: Array<{ id: string; ok: boolean; body: unknown }> = [] + const socket: HostRelaySocket = { respond: (id, ok, body) => responses.push({ id, ok, body }), sendFrame: () => true } + const write = vi.fn() + const pty: HostPtyManager = { createDetached: () => 'unused', attachDetached: () => 'owned-generation', + captureSnapshot: async () => 'screen', sessionExists: async () => true, + submitComposed, + // This isolated fake explicitly represents a tmux viewer; production resolves the backend. + scrollAttached: async (clientId, sessionId, up, lines, _capture, current) => { + if (!current()) return { status: 'refused', message: 'detached' } + for (let i = 0; i < lines; i++) write(clientId, sessionId, `\x1b[<${up ? 64 : 65};1;1M`) + return { status: 'input' } + }, + write, resize: () => {}, setFlow: () => {}, kill: () => {} } + const fs: HostFsOps = { listDir: async () => [], readText: async () => '', readBinary: async () => '', writeText: async () => true } + const handlers = createHostHandlers(pty, socket, fs, () => []) + const attach = async (): Promise => { + handlers.onRpc({ id: 'attach', method: 'pty.attach', params: { nodeId: 'ours', cols: 80, rows: 24 } }) + await flush() + return (responses.find((r) => r.id === 'attach')!.body as { streamId: number }).streamId + } + return { handlers, responses, attach, write } +} +const input = { kind: 'paste', text: 'a multiline\nΩ draft longer than sixteen characters', enter: true } as const + +describe('pty.submitComposed viewer-scoped RPC', () => { + it('uses only the attached generation, keeping full drafts outside node.sendKeys/raw input', async () => { + const submit = vi.fn(async (_sessionId: string, _input: ComposedInput, _current: () => boolean): Promise => ({ status: 'delivered' })) + const f = fixture(submit), streamId = await f.attach() + f.handlers.onRpc({ id: 'submit', method: 'pty.submitComposed', params: { streamId, input, + nodeId: 'foreign', sessionId: 'foreign', paneId: '%666', socket: 'foreign' } }) + await flush() + expect(submit).toHaveBeenCalledWith('owned-generation', input, expect.any(Function)) + expect(submit.mock.calls[0][2]()).toBe(true) + expect(f.responses.at(-1)).toEqual({ id: 'submit', ok: true, body: { status: 'delivered' } }) + expect(f.write).not.toHaveBeenCalled() + }) + it('refuses legacy implementations, unknown methods/streams and malformed inputs without fallback', async () => { + const f = fixture(), streamId = await f.attach() + f.handlers.onRpc({ id: 'legacy', method: 'pty.submitComposed', params: { streamId, input } }) + expect(f.responses.at(-1)).toEqual({ id: 'legacy', ok: false, body: { message: COMPOSED_INPUT_UNSUPPORTED } }) + f.handlers.onRpc({ id: 'unknown', method: 'pty.submitComposedOld', params: { streamId, input } }) + expect(f.responses.at(-1)?.ok).toBe(false) + expect(f.write).not.toHaveBeenCalled() + const submit = vi.fn(async (): Promise => ({ status: 'delivered' })) + const g = fixture(submit), owned = await g.attach() + for (const params of [{ streamId: 999, input }, { streamId: owned, input: { ...input, kind: 'keys' } }, + { streamId: owned, input: { kind: 'control', text: '\x03', enter: true } }]) + g.handlers.onRpc({ id: 'refuse', method: 'pty.submitComposed', params }) + expect(submit).not.toHaveBeenCalled() + expect(g.write).not.toHaveBeenCalled() + }) + it('provides the captured stream-current callback and preserves uncertain delivery without retry', async () => { + let resolve!: (value: ComposedInputResult) => void + let current!: () => boolean + const submit = vi.fn((_id, _input, valid) => { current = valid; return new Promise((done) => { resolve = done }) }) + const f = fixture(submit), streamId = await f.attach() + f.handlers.onRpc({ id: 'submit', method: 'pty.submitComposed', params: { streamId, input } }) + expect(current()).toBe(true) + f.handlers.onRpc({ id: 'kill', method: 'pty.kill', params: { streamId } }) + expect(current()).toBe(false) + resolve({ status: 'uncertain', message: 'lost acknowledgment' }); await flush() + expect(f.responses.at(-1)).toEqual({ id: 'submit', ok: true, body: { status: 'uncertain', message: 'lost acknowledgment' } }) + expect(submit).toHaveBeenCalledTimes(1) + expect(f.write).not.toHaveBeenCalled() + }) + it('keeps reports raw and routes an explicitly known tmux viewer separately from composed input', async () => { + const submit = vi.fn(async (): Promise => ({ status: 'delivered' })) + const f = fixture(submit), streamId = await f.attach() + const report = '\x1b[12;34R' + f.handlers.onFrame({ op: OP.Input, streamId, seq: 0, payload: new TextEncoder().encode(report) }) + f.handlers.onRpc({ id: 'scroll', method: 'pty.scroll', params: { streamId, dir: 'up', lines: 2 } }) + await flush() + expect(f.write.mock.calls.map((call) => call[2])).toEqual([report, '\x1b[<64;1;1M', '\x1b[<64;1;1M']) + expect(submit).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/remote/host-history-scroll.test.ts b/src/main/remote/host-history-scroll.test.ts new file mode 100644 index 0000000000..ca082c5b04 --- /dev/null +++ b/src/main/remote/host-history-scroll.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createHostHandlers, type HostFsOps, type HostPtyManager, type HostRelaySocket } from './host-service' +import { NativeWindowsPane } from '../../core/native-windows-pane' +import { HISTORY_VIEW_TTL_MS } from '../../core/history-scroll-view' +import type { HistoryScrollCapture, HistoryScrollPage, NativeScrollResult } from '../../shared/history-scroll' + +const flush = async () => { await new Promise(resolve => setImmediate(resolve)) } +const cleanups: Array<() => void> = [] +afterEach(() => { cleanups.splice(0).reverse().forEach(close => close()); vi.useRealTimers() }) +const capture = (): HistoryScrollCapture => ({ cols: 80, viewportRows: 4, olderTruncated: false, + rows: Array.from({ length: 40 }, (_, i) => ({ text: `retained ${i}`, isWrapped: i === 2, section: 'normal' })) }) +function fixture(scroll?: HostPtyManager['scrollAttached']) { + const responses: Array<{ id: string; ok: boolean; body: unknown }> = [], write = vi.fn() + const socket: HostRelaySocket = { respond: (id, ok, body) => responses.push({ id, ok, body }), sendFrame: () => true } + let next = 0 + const pty: HostPtyManager = { scrollAttached: scroll, createDetached: () => 'unused', + attachDetached: () => `owned-${++next}`, captureSnapshot: async () => '', sessionExists: async () => true, + write, resize: () => {}, setFlow: () => {}, kill: () => {} } + const fs: HostFsOps = { listDir: async () => [], readText: async () => '', readBinary: async () => '', writeText: async () => true } + const handlers = createHostHandlers(pty, socket, fs, () => [], async () => '', () => 17) + cleanups.push(() => handlers.closeAll()) + const request = (id: string, method: string, params: unknown) => handlers.onRpc({ id, method, params }) + const response = (id: string) => { const result = responses.find(r => r.id === id); + expect(result, `missing host response ${id}`).toBeDefined(); return result! } + const attach = async (id = 'attach') => { request(id, 'pty.attach', { nodeId: id }); await flush(); + return (response(id).body as { streamId: number }).streamId } + const page = (id: string) => response(id).body as HistoryScrollPage + const answered = async (id: string) => { for (let n = 0; n < 1000 && !responses.some(r => r.id === id); n++) await new Promise(r => setTimeout(r, 1)); + expect(response(id), `missing response ${id}`).toBeDefined() } + return { responses, request, response, attach, page, answered, write, handlers } +} + +describe('negotiated, viewer-owned history scrolling', () => { + it('advertises only the actual route and refuses an older implementation without injecting input', async () => { + const f = fixture(), streamId = await f.attach() + expect(f.response('attach').body).toEqual({ streamId, fresh: false }) + f.request('old', 'pty.scrollV1', { streamId, dir: 'up', lines: 1 }) + expect(f.response('old').body).toMatchObject({ status: 'refused', message: expect.stringContaining('Update') }) + f.request('legacy', 'pty.scroll', { streamId, dir: 'up', lines: 1 }) + expect(f.response('legacy').ok).toBe(false); expect(f.write).not.toHaveBeenCalled() + }) + it('pages a frozen capture across new output and checks application mode only for a new attached-view intent', async () => { + const source = capture(), scroll = vi.fn(async (_client, _session, _up, _lines, needCapture, _current: () => boolean): Promise => + needCapture ? { status: 'history', capture: source } : { status: 'history' }) + const f = fixture(scroll), streamId = await f.attach() + expect(f.response('attach').body).toMatchObject({ scrollV1: true }) + f.request('up', 'pty.scrollV1', { streamId, dir: 'up', lines: 2, sessionId: 'foreign', nodeId: 'foreign' }); await flush() + const page = f.page('up') + expect(page.offset).toBe(6); expect(page.rows.map(r => r.text)).toEqual(['retained 30', 'retained 31', 'retained 32', 'retained 33']) + source.rows.splice(0, source.rows.length, { text: 'new output replaced the live buffer', isWrapped: false, section: 'normal' }) + f.request('down', 'pty.scrollV1', { streamId, dir: 'down', lines: 1, viewId: page.viewId }); await flush() + expect(f.page('down').offset).toBe(3); expect(f.page('down').rows[0].text).toBe('retained 33') + expect(scroll.mock.calls.map(c => c.slice(0, 5))).toEqual([[17, 'owned-1', true, 2, true]]) + expect(scroll.mock.calls.every(c => c[5]())).toBe(true); expect(f.write).not.toHaveBeenCalled() + }) + it('keeps two independent viewer positions and refuses another stream’s view id before its backend', async () => { + const scroll = vi.fn(async (): Promise => ({ status: 'history', capture: capture() })) + const f = fixture(scroll), a = await f.attach('a'), b = await f.attach('b') + f.request('pa', 'pty.scrollV1', { streamId: a, dir: 'up', lines: 2 }); await flush() + f.request('pb', 'pty.scrollV1', { streamId: b, dir: 'up', lines: 1 }); await flush() + expect(f.page('pa').offset).toBe(6); expect(f.page('pb').offset).toBe(3) + expect(f.page('pa').viewId).not.toBe(f.page('pb').viewId) + f.request('foreign', 'pty.scrollV1', { streamId: b, dir: 'up', lines: 1, viewId: f.page('pa').viewId }); await flush() + expect(f.response('foreign').body).toMatchObject({ status: 'refused' }); expect(scroll).toHaveBeenCalledTimes(2) + }) + it('rejects malformed untrusted movements and unready streams without backend or raw writes', async () => { + const scroll = vi.fn(async (): Promise => ({ status: 'input' })) + const f = fixture(scroll) + f.request('attach', 'pty.attach', { nodeId: 'node' }) + f.request('unready', 'pty.scrollV1', { streamId: 1, dir: 'up', lines: 1 }) + await flush() + for (const [i, params] of [{ streamId: 999, dir: 'up', lines: 1 }, { streamId: 1, dir: 'left', lines: 1 }, + { streamId: 1.5, dir: 'up', lines: 1 }, { streamId: 1, dir: 'up', lines: 0 }, { streamId: 1, dir: 'up', lines: 21 }, + { streamId: 1, dir: 'up', lines: 1.5 }, { streamId: 1, dir: 'up', lines: 1, viewId: null }, + { streamId: 1, dir: 'up', lines: 1, viewId: 'foreign' }].entries()) { + f.request(`bad-${i}`, 'pty.scrollV1', params); expect(f.response(`bad-${i}`).body).toMatchObject({ status: 'refused' }) + } + expect(f.response('unready').body).toMatchObject({ status: 'refused' }) + expect(scroll).not.toHaveBeenCalled(); expect(f.write).not.toHaveBeenCalled() + }) + it('releases a history view on app-directed wheel input and preserves refused or uncertain results without retry', async () => { + let mode = 'history' + const scroll = vi.fn(async (): Promise => mode === 'history' ? { status: 'history', capture: capture() } + : mode === 'input' ? { status: 'input' } : { status: mode as 'refused' | 'uncertain', message: mode }) + const f = fixture(scroll), streamId = await f.attach() + f.request('page', 'pty.scrollV1', { streamId, dir: 'up', lines: 1 }); await flush() + const viewId = f.page('page').viewId + mode = 'input'; f.request('still-history', 'pty.scrollV1', { streamId, dir: 'up', lines: 1, viewId }); await flush() + expect(f.page('still-history').status).toBe('history'); expect(scroll).toHaveBeenCalledTimes(1) + f.request('input', 'pty.scrollV1', { streamId, dir: 'up', lines: 1 }); await flush() + expect(f.response('input').body).toEqual({ status: 'input' }) + f.request('old-id', 'pty.scrollV1', { streamId, dir: 'up', lines: 1, viewId }); await flush() + expect(f.response('old-id').body).toMatchObject({ status: 'refused' }); expect(scroll).toHaveBeenCalledTimes(2) + for (mode of ['refused', 'uncertain']) { f.request(mode, 'pty.scrollV1', { streamId, dir: 'up', lines: 1 }); await flush() + expect(f.response(mode).body).toEqual({ status: mode, message: mode }) } + expect(scroll).toHaveBeenCalledTimes(4); expect(f.write).not.toHaveBeenCalled() + }) + it('makes a thrown mixed operation uncertain once and refuses a capture completed after detach', async () => { + const thrown = vi.fn(async (): Promise => { throw new Error('possibly written') }) + const f = fixture(thrown), id = await f.attach(); f.request('throw', 'pty.scrollV1', { streamId: id, dir: 'up', lines: 1 }); await flush() + expect(f.response('throw').body).toMatchObject({ status: 'uncertain' }); expect(thrown).toHaveBeenCalledTimes(1) + let release!: (r: NativeScrollResult) => void, current!: () => boolean + const held = vi.fn((_c, _s, _u, _n, _cap, valid) => { current = valid; return new Promise(done => { release = done }) }) + const g = fixture(held), streamId = await g.attach(); g.request('held', 'pty.scrollV1', { streamId, dir: 'up', lines: 1 }); await flush() + expect(current()).toBe(true); g.request('kill', 'pty.kill', { streamId }); expect(current()).toBe(false) + release({ status: 'history', capture: capture() }); await flush() + expect(g.response('held').body).toMatchObject({ status: 'refused', message: expect.stringContaining('detached') }) + expect(held).toHaveBeenCalledTimes(1) + }) + it('serializes reversals and caps pending work instead of unbounded snapshot allocation', async () => { + let release!: () => void, first = true + const scroll = vi.fn(async (_c, _s, _u, _n, need): Promise => { + if (first) { first = false; await new Promise(done => { release = done }) } + return need ? { status: 'history', capture: capture() } : { status: 'history' } + }) + const f = fixture(scroll), streamId = await f.attach() + f.request('held', 'pty.scrollV1', { streamId, dir: 'up', lines: 1 }); await flush() + for (let i = 1; i <= 32; i++) f.request(`queue-${i}`, 'pty.scrollV1', { streamId, dir: i % 2 ? 'down' : 'up', lines: 1 }) + await flush() // A second turn must remain blocked while the first backend call is held. + expect(scroll).toHaveBeenCalledTimes(1); expect(f.response('queue-32').body).toMatchObject({ status: 'refused', message: expect.stringContaining('busy') }) + release(); await flush(); expect(scroll).toHaveBeenCalledTimes(32) + expect(scroll.mock.calls.slice(0, 3).map(c => c[2])).toEqual([true, false, true]) + }) + it('expires held snapshots and requires a fresh user intent rather than silently reusing another live buffer', async () => { + const scroll = vi.fn(async (): Promise => ({ status: 'history', capture: capture() })) + const f = fixture(scroll), streamId = await f.attach() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) + f.request('old', 'pty.scrollV1', { streamId, dir: 'up', lines: 1 }); await flush() + vi.advanceTimersByTime(HISTORY_VIEW_TTL_MS + 1) + f.request('expired', 'pty.scrollV1', { streamId, dir: 'up', lines: 1, viewId: f.page('old').viewId }); await flush() + expect(f.response('expired').body).toMatchObject({ status: 'refused', message: expect.stringContaining('expired') }) + expect(scroll).toHaveBeenCalledTimes(1) + f.request('new', 'pty.scrollV1', { streamId, dir: 'up', lines: 1 }); await flush() + expect(f.page('new').viewId).not.toBe(f.page('old').viewId); expect(scroll).toHaveBeenCalledTimes(2) + f.handlers.closeAll(); f.request('gone', 'pty.scrollV1', { streamId, dir: 'up', lines: 1 }) + expect(f.response('gone').body).toMatchObject({ status: 'refused' }) + }) +}) + +it('actual native emulator pages retained pre-attach history with zero off-mode bytes and respects later SGR/default mode changes', async () => { + const write = vi.fn(), pane = new NativeWindowsPane({ pid: 42, write }, { cols: 20, rows: 5, scrollback: 100 }) + cleanups.push(() => pane.dispose()); pane.recordOutput('pre-attach older row\r\n' + 'history row\r\n'.repeat(35)) + const f = fixture((_c, _s, up, lines, need, current) => pane.scrollForHistory(up, lines, need, current)), streamId = await f.attach() + f.request('history', 'pty.scrollV1', { streamId, dir: 'up', lines: 20 }); await f.answered('history') + expect(f.page('history').rows.some(r => r.text.includes('pre-attach older'))).toBe(true); expect(write).not.toHaveBeenCalled() + pane.recordOutput('\x1b[?1000h\x1b[?1006h') + f.request('still-history', 'pty.scrollV1', { streamId, dir: 'down', lines: 2, viewId: f.page('history').viewId }); await f.answered('still-history') + expect(f.page('still-history').status).toBe('history'); expect(write).not.toHaveBeenCalled(); expect(f.write).not.toHaveBeenCalled() + f.request('sgr', 'pty.scrollV1', { streamId, dir: 'down', lines: 2 }); await f.answered('sgr') + expect(f.response('sgr').body).toEqual({ status: 'input' }); expect(write.mock.calls).toEqual([['\x1b[<65;1;1M'], ['\x1b[<65;1;1M']]) + write.mockClear(); pane.recordOutput('\x1b[?1006l') + f.request('default', 'pty.scrollV1', { streamId, dir: 'up', lines: 1 }); await f.answered('default') + expect(write.mock.calls).toEqual([['\x1b[M`!!']]) + write.mockClear(); pane.recordOutput('\x1b[?1000l') + f.request('legacy', 'pty.scroll', { streamId, dir: 'up', lines: 2 }); await f.answered('legacy') + expect(f.response('legacy').ok).toBe(false); expect(write).not.toHaveBeenCalled(); expect(f.write).not.toHaveBeenCalled() +}) diff --git a/src/main/remote/host-history.test.ts b/src/main/remote/host-history.test.ts new file mode 100644 index 0000000000..ba240deac5 --- /dev/null +++ b/src/main/remote/host-history.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { createHostHandlers, type HostFsOps, type HostPtyManager, type HostRelaySocket } from './host-service' +import { searchTerminalHistory, type HistorySearch } from '../../core/terminal-history' + +const flush = async (): Promise => { await new Promise((resolve) => setImmediate(resolve)) } +function fixture(historySearch?: HostPtyManager['historySearch']) { + const responses: Array<{ id: string; ok: boolean; body: unknown }> = [] + const socket: HostRelaySocket = { respond: (id, ok, body) => responses.push({ id, ok, body }), sendFrame: () => true } + const pty: HostPtyManager = { + createDetached: () => 'unused', attachDetached: () => 'owned-generation', captureSnapshot: async () => 'screen', sessionExists: async () => true, + historySearch, write: () => {}, resize: () => {}, setFlow: () => {}, kill: () => {} + } + const fs: HostFsOps = { listDir: async () => [], readText: async () => '', readBinary: async () => '', writeText: async () => true } + const handlers = createHostHandlers(pty, socket, fs, () => []) + const attach = async (): Promise => { + handlers.onRpc({ id: 'attach', method: 'pty.attach', params: { nodeId: 'ours', cols: 80, rows: 24 } }) + await flush() + const reply = responses.find((r) => r.id === 'attach')!.body as { streamId: number } + return reply.streamId + } + return { handlers, responses, attach } +} + +describe('pty.historySearch', () => { + it('uses only the actual attached generation, ignoring caller-selected target/path', async () => { + const search = vi.fn(async (_id: string, query: string) => searchTerminalHistory('old Ω\n' + 'recent\n'.repeat(500), query)) + const f = fixture(search) + const streamId = await f.attach() + f.handlers.onRpc({ id: 'search', method: 'pty.historySearch', params: { streamId, query: 'Ω', nodeId: 'foreign', sessionId: 'foreign', cwd: '/' } }) + await flush() + expect(search).toHaveBeenCalledWith('owned-generation', 'Ω') + expect(f.responses.at(-1)).toEqual({ id: 'search', ok: true, body: { searchedLines: 501, truncated: false, rows: [{ line: 0, text: 'old Ω' }] } }) + }) + it('refuses unknown streams and malformed queries before capture', async () => { + const search = vi.fn(async () => searchTerminalHistory('', 'x')) + const f = fixture(search) + f.handlers.onRpc({ id: 'bad-stream', method: 'pty.historySearch', params: { streamId: 99, query: 'x' } }) + const streamId = await f.attach() + for (const query of ['', 'x\ny', 'x'.repeat(257), 1]) f.handlers.onRpc({ id: 'bad-query', method: 'pty.historySearch', params: { streamId, query } }) + expect(search).not.toHaveBeenCalled() + expect(f.responses.filter((r) => r.id !== 'attach').every((r) => !r.ok)).toBe(true) + }) + it('returns honest older-host and capture errors', async () => { + for (const search of [undefined, async (): Promise => { throw new Error('capture failed') }]) { + const f = fixture(search) + const streamId = await f.attach() + f.handlers.onRpc({ id: 'search', method: 'pty.historySearch', params: { streamId, query: 'x' } }) + await flush() + expect(f.responses.at(-1)?.ok).toBe(false) + expect(f.responses.at(-1)?.body).toHaveProperty('message', search ? 'capture failed' : 'History search is not served on this host. Update nodeterm on the computer.') + } + }) + it('rejects results if the stream detached during capture', async () => { + let resolve!: (value: HistorySearch) => void + const f = fixture(() => new Promise((done) => { resolve = done })) + const streamId = await f.attach() + f.handlers.onRpc({ id: 'search', method: 'pty.historySearch', params: { streamId, query: 'x' } }) + f.handlers.onRpc({ id: 'kill', method: 'pty.kill', params: { streamId } }) + resolve({ searchedLines: 1, truncated: false, rows: [{ line: 0, text: 'x' }] }) + await flush() + expect(f.responses.at(-1)).toEqual({ id: 'search', ok: false, body: { message: 'The terminal detached while its history was searched.' } }) + }) +}) diff --git a/src/main/remote/host-inbox-verbs.test.ts b/src/main/remote/host-inbox-verbs.test.ts new file mode 100644 index 0000000000..589451a1cb --- /dev/null +++ b/src/main/remote/host-inbox-verbs.test.ts @@ -0,0 +1,187 @@ +// The relay phone's Inbox verbs (`approvals.answer` / `inbox.ack`). What these pin: +// - Absent injection ⇒ an honest "not served", never a silent success. +// - Every client-sent field is validated BEFORE the writer is asked: the pendingId becomes a file +// name (`~/.nodeterm/pending/.answer`), so a traversal-shaped id must never reach it, and the +// decision is exactly `allow` | `deny` — the literals the held hook script compares against. +// - A writer that did not deliver comes back as an ok ANSWER `{answered:false, reason}`: `gone` (the +// hook's hold already ended — A06: the writer checks the request file first) or `failed` (the write +// could not happen), so the phone can open the session or offer a retry instead of a protocol error. +// - `inbox.ack` runs the SAME ack the `~/.nodeterm/acks` sweep runs for a phone on direct SSH. +import { describe, expect, it, vi } from 'vitest' +import { + createHostHandlers, + type HostFsOps, + type HostInboxOps, + type HostPtyManager, + type HostRelaySocket +} from './host-service' + +function makeFakes(over: Partial = {}, served = true) { + const responses: Array<{ id: string; ok: boolean; body: any }> = [] + const socket: HostRelaySocket = { + respond: (id, ok, body) => responses.push({ id, ok, body }), + sendFrame: () => true + } + const fs: HostFsOps = { + listDir: async () => [], + readText: async () => '', + readBinary: async () => '', + writeText: async () => true + } + const inbox: HostInboxOps = { + answerPermission: vi.fn(async () => 'sent' as const), + ackRead: vi.fn(), + ...over + } + const handlers = createHostHandlers( + {} as HostPtyManager, socket, fs, () => [], + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + served ? inbox : undefined + ) + return { handlers, responses, inbox } +} + +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)) + +describe('approvals.answer', () => { + it('routes a valid answer to the shared writer and reports it', async () => { + const { handlers, responses, inbox } = makeFakes() + handlers.onRpc({ + id: '1', + method: 'approvals.answer', + params: { nodeId: 'term-abc-1', pendingId: 'term-abc-1-1700000000000-4242', decision: 'allow' } + }) + await flush() + expect(inbox.answerPermission).toHaveBeenCalledWith('term-abc-1', 'term-abc-1-1700000000000-4242', 'allow') + expect(responses[0]).toEqual({ id: '1', ok: true, body: { answered: true } }) + }) + + it('answers {answered:false, reason:"gone"} — an ANSWER, not an error — when the hold had ended', async () => { + const { handlers, responses } = makeFakes({ answerPermission: async () => 'gone' }) + handlers.onRpc({ + id: '2', + method: 'approvals.answer', + params: { nodeId: 'term-abc-1', pendingId: 'p-1', decision: 'deny' } + }) + await flush() + expect(responses[0]).toEqual({ id: '2', ok: true, body: { answered: false, reason: 'gone' } }) + }) + + it('tells a failed write apart from an ended hold (A35)', async () => { + const { handlers, responses } = makeFakes({ answerPermission: async () => 'failed' }) + handlers.onRpc({ + id: '2b', + method: 'approvals.answer', + params: { nodeId: 'term-abc-1', pendingId: 'p-1', decision: 'allow' } + }) + await flush() + expect(responses[0]).toEqual({ id: '2b', ok: true, body: { answered: false, reason: 'failed' } }) + }) + + it('a writer that throws is still an answer, not a hung request', async () => { + const { handlers, responses } = makeFakes({ + answerPermission: async () => { + throw new Error('disk full') + } + }) + handlers.onRpc({ id: '3', method: 'approvals.answer', params: { nodeId: 'n', pendingId: 'p', decision: 'allow' } }) + await flush() + expect(responses[0]).toEqual({ id: '3', ok: true, body: { answered: false, reason: 'failed' } }) + }) + + it.each([ + ['a traversal-shaped pendingId', { nodeId: 'n', pendingId: '../../etc/passwd', decision: 'allow' }], + ['an empty pendingId', { nodeId: 'n', pendingId: '', decision: 'allow' }], + ['a decision that is not allow/deny', { nodeId: 'n', pendingId: 'p', decision: 'always' }], + ['a missing decision', { nodeId: 'n', pendingId: 'p' }], + ['a control char in the node id', { nodeId: 'n\u001b[2J', pendingId: 'p', decision: 'allow' }], + ['a missing node id', { pendingId: 'p', decision: 'allow' }] + ])('refuses %s before the writer is asked', async (_label, params) => { + const { handlers, responses, inbox } = makeFakes() + handlers.onRpc({ id: '4', method: 'approvals.answer', params }) + await flush() + expect(inbox.answerPermission).not.toHaveBeenCalled() + expect(responses[0].ok).toBe(false) + }) + + it('is "not served" on a host without the injection', async () => { + const { handlers, responses } = makeFakes({}, false) + handlers.onRpc({ id: '5', method: 'approvals.answer', params: { nodeId: 'n', pendingId: 'p', decision: 'allow' } }) + await flush() + expect(responses[0].ok).toBe(false) + expect(responses[0].body.message).toMatch(/not served/) + }) +}) + +describe('inbox.ack', () => { + it('runs the shared read-ack for the node', async () => { + const { handlers, responses, inbox } = makeFakes() + handlers.onRpc({ id: '6', method: 'inbox.ack', params: { nodeId: 'term-abc-1' } }) + await flush() + expect(inbox.ackRead).toHaveBeenCalledWith('term-abc-1') + expect(responses[0]).toEqual({ id: '6', ok: true, body: {} }) + }) + + it('still answers when the ack throws (best-effort, like the file sweep)', async () => { + const { handlers, responses } = makeFakes({ + ackRead: () => { + throw new Error('boom') + } + }) + handlers.onRpc({ id: '7', method: 'inbox.ack', params: { nodeId: 'n' } }) + await flush() + expect(responses[0]).toEqual({ id: '7', ok: true, body: {} }) + }) + + it('refuses an invalid node id and a host without the injection', async () => { + const a = makeFakes() + a.handlers.onRpc({ id: '8', method: 'inbox.ack', params: { nodeId: '' } }) + const b = makeFakes({}, false) + b.handlers.onRpc({ id: '9', method: 'inbox.ack', params: { nodeId: 'n' } }) + await flush() + expect(a.inbox.ackRead).not.toHaveBeenCalled() + expect(a.responses[0].ok).toBe(false) + expect(b.responses[0].ok).toBe(false) + }) +}) + + +describe('structured hook answer verbs', () => { + it('routes a remembered rule by index rather than accepting client-supplied rules', async () => { + const { handlers, responses, inbox } = makeFakes() + handlers.onRpc({ id: 'remember', method: 'approvals.answer', params: { nodeId: 'n', pendingId: 'n-1-1', decision: 'allow-always', suggestionIndex: 2 } }) + await flush() + expect(inbox.answerPermission).toHaveBeenCalledWith('n', 'n-1-1', 'allow-always', 2) + expect(responses[0]).toEqual({ id: 'remember', ok: true, body: { answered: true } }) + }) + it.each([-1, 32, 1.2, undefined, '1'])('rejects invalid suggestion index %s', async suggestionIndex => { + const { handlers, responses, inbox } = makeFakes() + handlers.onRpc({ id: 'bad', method: 'approvals.answer', params: { nodeId: 'n', pendingId: 'n-1-1', decision: 'allow-always', suggestionIndex } }) + await flush() + expect(inbox.answerPermission).not.toHaveBeenCalled() + expect(responses[0].ok).toBe(false) + }) + it('submits every question selection in one call and preserves a gone result', async () => { + const answerQuestion = vi.fn(async () => 'gone' as const) + const { handlers, responses } = makeFakes({ answerQuestion }) + handlers.onRpc({ id: 'question', method: 'questions.answer', params: { nodeId: 'n', pendingId: 'n-1-1', selections: [[0, 1], [1]] } }) + await flush() + expect(answerQuestion).toHaveBeenCalledWith('n', 'n-1-1', [[0, 1], [1]]) + expect(responses[0]).toEqual({ id: 'question', ok: true, body: { answered: false, reason: 'gone' } }) + }) + it.each([[], [[]], [[-1]], [[4]], [[0.5]], [[0, 1, 2, 3, 0]], [[0], [0], [0], [0], [0]], [['1']]].map(selections => ({ selections })))('rejects malformed question selections $selections', async ({ selections }) => { + const answerQuestion = vi.fn(async () => 'sent' as const) + const { handlers, responses } = makeFakes({ answerQuestion }) + handlers.onRpc({ id: 'bad-q', method: 'questions.answer', params: { nodeId: 'n', pendingId: 'n-1-1', selections } }) + await flush() + expect(answerQuestion).not.toHaveBeenCalled() + expect(responses[0].ok).toBe(false) + }) + it('honestly refuses the new verb on an older injected host', async () => { + const { handlers, responses } = makeFakes() + handlers.onRpc({ id: 'old', method: 'questions.answer', params: { nodeId: 'n', pendingId: 'n-1-1', selections: [[0]] } }) + await flush() + expect(responses[0].ok).toBe(false) + expect(responses[0].body.message).toMatch(/not served/) + }) +}) diff --git a/src/main/remote/host-lan-report.test.ts b/src/main/remote/host-lan-report.test.ts new file mode 100644 index 0000000000..59f1ac4880 --- /dev/null +++ b/src/main/remote/host-lan-report.test.ts @@ -0,0 +1,226 @@ +// Audit A74-refresh: what this computer reports about its own direct-SSH leg beside every relay +// `projects.list` answer, and that the answer carries it additively. +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import os from 'os' +import path from 'path' +import { createHostLanReporter, HOST_KEYS_TTL_MS, type HostLanReport } from './host-lan-report' +import { pairingNetworkIPv4, type PairingNetworkAddress } from '../../shared/pairing-network' +import { createHostHandlers, type HostFsOps, type HostPtyManager, type HostRelaySocket } from './host-service' + +// GitHub's published host keys and the fingerprints OpenSSH prints for them (the same outside vector +// ssh-host-keys.test.ts uses): the format the sealed /pair answer already carries. +const GITHUB_ED25519 = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' +const GITHUB_ED25519_FP = 'SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU' +const GITHUB_ECDSA = + 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=' +const GITHUB_ECDSA_FP = 'SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM' + +const dirs: string[] = [] +const tempDir = (): string => { + const d = mkdtempSync(path.join(os.tmpdir(), 'nt-lan-report-')) + dirs.push(d) + return d +} +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) +}) + +/** A laptop with loopback, a link-local leftover, Wi-Fi and a Docker bridge, in the order the OS lists them. */ +const LAPTOP: Record = { + lo: [{ address: '127.0.0.1', family: 'IPv4', internal: true }], + eth0: [{ address: '169.254.10.2', family: 'IPv4', internal: false }], + wlan0: [ + { address: 'fe80::1', family: 'IPv6', internal: false }, + { address: '192.168.1.42', family: 'IPv4', internal: false } + ], + docker0: [{ address: '172.17.0.1', family: 'IPv4', internal: false }] +} + +const noKeys = (): string[] => [path.join(tempDir(), 'missing')] + +describe('createHostLanReporter', () => { + it('reports the address the pairing QR would carry and the host keys the sealed answer names', async () => { + const keys = tempDir() + writeFileSync(path.join(keys, 'ssh_host_ed25519_key.pub'), `${GITHUB_ED25519} root@box\n`) + writeFileSync(path.join(keys, 'ssh_host_ecdsa_key.pub'), `${GITHUB_ECDSA} root@box\n`) + const report = await createHostLanReporter({ platform: 'linux', interfaces: () => LAPTOP, sshHostKeyDirs: [keys] })() + // The same pick as the QR's `host` (pairing-service.ts), not a second opinion about it. + expect(report?.host).toBe(pairingNetworkIPv4(LAPTOP)) + expect(report).toEqual({ host: '192.168.1.42', sshHostKeyFingerprints: [GITHUB_ECDSA_FP, GITHUB_ED25519_FP] }) + }) + + it('leaves out what it does not know, and answers null when it knows nothing', async () => { + const keys = tempDir() + writeFileSync(path.join(keys, 'ssh_host_ed25519_key.pub'), `${GITHUB_ED25519}\n`) + const offline = { lo: LAPTOP.lo } + expect(await createHostLanReporter({ platform: 'linux', interfaces: () => offline, sshHostKeyDirs: [keys] })()).toEqual({ + sshHostKeyFingerprints: [GITHUB_ED25519_FP] + }) + expect(await createHostLanReporter({ platform: 'darwin', interfaces: () => LAPTOP, sshHostKeyDirs: noKeys() })()).toEqual({ + host: '192.168.1.42' + }) + expect(await createHostLanReporter({ platform: 'linux', interfaces: () => offline, sshHostKeyDirs: noKeys() })()).toBeNull() + // A failing interface read is "no address", never a rejection. + const broken = createHostLanReporter({ + platform: 'linux', + interfaces: () => { + throw new Error('EPERM') + }, + sshHostKeyDirs: [keys] + }) + expect(await broken()).toEqual({ sshHostKeyFingerprints: [GITHUB_ED25519_FP] }) + }) + + it('reports nothing on Windows, which pairs relay-only (no SSH leg to refresh)', async () => { + const keys = tempDir() + writeFileSync(path.join(keys, 'ssh_host_ed25519_key.pub'), `${GITHUB_ED25519}\n`) + const interfaces = vi.fn(() => LAPTOP) + expect(await createHostLanReporter({ platform: 'win32', interfaces, sshHostKeyDirs: [keys] })()).toBeNull() + expect(interfaces).not.toHaveBeenCalled() + }) + + it('reads the host keys once a minute, the address every time', async () => { + const keys = tempDir() + writeFileSync(path.join(keys, 'ssh_host_ed25519_key.pub'), `${GITHUB_ED25519}\n`) + let t = 1_000 + let addr = '192.168.1.42' + const report = createHostLanReporter({ + platform: 'linux', + interfaces: () => ({ wlan0: [{ address: addr, family: 'IPv4', internal: false }] }), + sshHostKeyDirs: [keys], + now: () => t + }) + expect(await report()).toEqual({ host: '192.168.1.42', sshHostKeyFingerprints: [GITHUB_ED25519_FP] }) + // The keys are regenerated and the lease moves within the minute: the address is new at once… + writeFileSync(path.join(keys, 'ssh_host_ed25519_key.pub'), `${GITHUB_ECDSA}\n`) + addr = '192.168.1.77' + t += HOST_KEYS_TTL_MS - 1 + expect(await report()).toEqual({ host: '192.168.1.77', sshHostKeyFingerprints: [GITHUB_ED25519_FP] }) + // …and the keys are re-read once the minute is up. + t += 1 + expect(await report()).toEqual({ host: '192.168.1.77', sshHostKeyFingerprints: [GITHUB_ECDSA_FP] }) + }) + + it('prefers a LAN adapter ahead of an earlier container or tunnel address', async () => { + const report = createHostLanReporter({ platform: 'linux', interfaces: () => ({ + docker0: LAPTOP.docker0, wg0: [{ address: '10.7.0.2', family: 4, internal: false }], wlan0: LAPTOP.wlan0 + }), sshHostKeyDirs: noKeys() }) + expect(await report()).toEqual({ host: '192.168.1.42' }) + }) + + it('rereads the selected interface and DHCP address but never substitutes for an absent selection', async () => { + const keys = tempDir() + writeFileSync(path.join(keys, 'ssh_host_ed25519_key.pub'), `${GITHUB_ED25519}\n`) + let selected = 'wg0' + let tunnel = '10.7.0.2' + const report = createHostLanReporter({ platform: 'darwin', getPairingInterface: () => selected, + interfaces: () => ({ ...LAPTOP, ...(tunnel ? { wg0: [{ address: tunnel, family: 4, internal: false }] } : {}) }), + sshHostKeyDirs: [keys] }) + expect(await report()).toEqual({ host: '10.7.0.2', sshHostKeyFingerprints: [GITHUB_ED25519_FP] }) + tunnel = '10.7.0.9' + expect((await report())?.host).toBe('10.7.0.9') + tunnel = '' + expect(await report()).toEqual({ sshHostKeyFingerprints: [GITHUB_ED25519_FP] }) + selected = 'wlan0' + expect((await report())?.host).toBe('192.168.1.42') + selected = '' + expect((await report())?.host).toBe('192.168.1.42') + }) +}) + +// --- the projects.list answer -------------------------------------------------------------------- + +function fakes() { + const responses: Array<{ id: string; ok: boolean; body: unknown }> = [] + const socket: HostRelaySocket = { + respond: (id, ok, body) => responses.push({ id, ok, body }), + sendFrame: () => true + } + const fs: HostFsOps = { + listDir: async () => [], + readText: async () => '', + readBinary: async () => '', + writeText: async () => true + } + return { socket, responses, fs, pty: {} as HostPtyManager } +} + +/** Every pending promise chain settles before a macrotask runs. */ +const settle = (): Promise => new Promise((r) => setTimeout(r, 0)) + +/** createHostHandlers' positional arguments up to and including `lanReport`. */ +function handlersWith( + f: ReturnType, + listProjects: () => Promise, + lanReport?: () => Promise +) { + return createHostHandlers( + f.pty, + f.socket, + f.fs, + () => [], + listProjects, + () => null, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + lanReport + ) +} + +describe('projects.list carries the LAN report beside the blob (A74-refresh)', () => { + it('adds `lan` next to `output`, leaving the blob as it was', async () => { + const f = fakes() + const lan = { host: '192.168.1.42', sshHostKeyFingerprints: [GITHUB_ED25519_FP] } + handlersWith(f, async () => 'BLOB', async () => lan).onRpc({ id: '1', method: 'projects.list', params: undefined }) + await settle() + expect(f.responses).toEqual([{ id: '1', ok: true, body: { output: 'BLOB', lan } }]) + }) + + it('reads the report per request, so a moved lease reaches the next listing', async () => { + const f = fakes() + let host = '192.168.1.42' + const h = handlersWith(f, async () => 'BLOB', async () => ({ host })) + h.onRpc({ id: '1', method: 'projects.list', params: undefined }) + await settle() + host = '10.0.0.9' + h.onRpc({ id: '2', method: 'projects.list', params: undefined }) + await settle() + expect(f.responses.map((r) => (r.body as { lan?: { host: string } }).lan?.host)).toEqual(['192.168.1.42', '10.0.0.9']) + }) + + it('leaves `lan` out when there is nothing to report, the report fails, or nobody wired one', async () => { + const f = fakes() + handlersWith(f, async () => 'A', async () => null).onRpc({ id: '1', method: 'projects.list', params: undefined }) + handlersWith(f, async () => 'B', async () => { + throw new Error('boom') + }).onRpc({ id: '2', method: 'projects.list', params: undefined }) + handlersWith(f, async () => 'C', () => { + throw new Error('sync boom') + }).onRpc({ id: '3', method: 'projects.list', params: undefined }) + handlersWith(f, async () => 'D').onRpc({ id: '4', method: 'projects.list', params: undefined }) + await settle() + expect([...f.responses].sort((a, b) => a.id.localeCompare(b.id))).toEqual([ + { id: '1', ok: true, body: { output: 'A' } }, + { id: '2', ok: true, body: { output: 'B' } }, + { id: '3', ok: true, body: { output: 'C' } }, + { id: '4', ok: true, body: { output: 'D' } } + ]) + }) + + it('still reports the computer when the blob could not be built', async () => { + const f = fakes() + handlersWith(f, async () => { + throw new Error('workspace unreadable') + }, async () => ({ host: '192.168.1.42' })).onRpc({ id: '1', method: 'projects.list', params: undefined }) + await settle() + expect(f.responses).toEqual([{ id: '1', ok: true, body: { output: '', lan: { host: '192.168.1.42' } } }]) + }) +}) diff --git a/src/main/remote/host-lan-report.ts b/src/main/remote/host-lan-report.ts new file mode 100644 index 0000000000..7f4d33ff4f --- /dev/null +++ b/src/main/remote/host-lan-report.ts @@ -0,0 +1,85 @@ +// What this computer tells a paired phone about its own direct-SSH leg, next to every relay +// `projects.list` answer (audit A74-refresh). +// +// The phone's LAN leg dials the address the pairing QR carried (`pairingNetworkIPv4`, pairing-service.ts) and +// checks the SSH host key against what the sealed `/pair` answer named (`sshHostKeyFingerprints`, +// audit A49-anchor). Both are facts about the moment of pairing: a DHCP lease moves, and a reinstall +// regenerates sshd's keys. The relay leg authenticates this computer on its own (the phone pinned this +// computer's box key at pairing, and the session is E2EE to it and approved), so an answer that comes +// back over it can tell the phone the CURRENT address and keys, and the phone may update its record from +// it. Nothing taken over SSH ever does: the SSH leg is the thing being checked. +// +// Additive on the wire: `{ output, lan: { host?, sshHostKeyFingerprints? } }`. A phone that does not +// read `lan` (the iOS app today, an older Android build) sees the reply it always saw. + +import os from 'os' +import { pairingNetworkIPv4, type PairingInterfaces } from '../../shared/pairing-network' +import { readSshHostKeyFingerprints, SSH_HOST_KEY_DIRS } from '../ssh-host-keys' + +/** The `lan` field of a `projects.list` answer. Each part is left out when it is unknown. */ +export interface HostLanReport { + /** This computer's LAN IPv4, picked exactly as the pairing QR picks its `host`. */ + host?: string + /** `SHA256:…` of this computer's SSH host keys, exactly as the sealed `/pair` answer carries them. */ + sshHostKeyFingerprints?: string[] +} + +export interface HostLanReporterOptions { + /** Defaults to `process.platform`. */ + platform?: NodeJS.Platform + /** Defaults to `os.networkInterfaces`. */ + interfaces?: () => PairingInterfaces + /** Same saved adapter NAME used by pairing; read on each report. Empty means automatic. */ + getPairingInterface?: () => string + /** Where sshd's host keys and config are read from. Defaults to `SSH_HOST_KEY_DIRS`. */ + sshHostKeyDirs?: readonly string[] + /** How long one read of the host keys is reused. Defaults to `HOST_KEYS_TTL_MS`. */ + keysTtlMs?: number + /** Defaults to `Date.now`. */ + now?: () => number +} + +/** + * The phone lists every 8 s while a screen shows the computer. Host keys change on a reinstall, not + * between two polls, so one read of `/etc/ssh` serves a minute of them. The address is read every time + * (`os.networkInterfaces()` asks the kernel, no disk). + */ +export const HOST_KEYS_TTL_MS = 60_000 + +/** + * A reader of this computer's current `HostLanReport`. Never rejects. Answers null when there is + * nothing to report, and always on Windows: a Windows desktop pairs relay-only (pairing-service.ts, + * `directSsh`), so the phone has no SSH leg to refresh. + */ +export function createHostLanReporter(options: HostLanReporterOptions = {}): () => Promise { + const platform = options.platform ?? process.platform + if (platform === 'win32') return async () => null + const interfaces = options.interfaces ?? os.networkInterfaces + const dirs = options.sshHostKeyDirs ?? SSH_HOST_KEY_DIRS + const ttl = options.keysTtlMs ?? HOST_KEYS_TTL_MS + const now = options.now ?? Date.now + let keys: { at: number; read: Promise } | null = null + + const hostKeys = (): Promise => { + const t = now() + if (!keys || t - keys.at >= ttl) { + keys = { at: t, read: readSshHostKeyFingerprints(dirs).catch(() => []) } + } + return keys.read + } + + return async () => { + let host: string | null = null + try { + host = pairingNetworkIPv4(interfaces(), options.getPairingInterface?.() ?? '') + } catch { + host = null + } + const fingerprints = await hostKeys() + if (!host && !fingerprints.length) return null + return { + ...(host ? { host } : {}), + ...(fingerprints.length ? { sshHostKeyFingerprints: fingerprints } : {}) + } + } +} diff --git a/src/main/remote/host-new-sessions.test.ts b/src/main/remote/host-new-sessions.test.ts new file mode 100644 index 0000000000..4e0c05bc41 --- /dev/null +++ b/src/main/remote/host-new-sessions.test.ts @@ -0,0 +1,109 @@ +// Audits A33 + A72: everything a phone-started session is created with is resolved by the HOST — +// the project's folder, the account, the agent, and the pane's owner — from this machine's own +// index and settings. The phone names a project; it never supplies an owner. +import { describe, expect, it } from 'vitest' +import { createHostNewSessions, type HostNewSessionsDeps } from './host-new-sessions' +import type { CanvasNodeState } from '../../shared/types' + +const ENTRIES: Record = { + 'entry-local': { cwd: '/repo' }, + 'entry-ssh': { cwd: '/remote/repo', ssh: { server: { host: 'h' }, remoteCwd: '/srv' } }, + 'entry-inline': {} +} +const ACCOUNTS = [ + { id: 'acct-ok' }, + { id: 'acct-pending', pending: true }, + { id: 'acct-remote', host: 'u@h' } +] + +function resolver(over: Partial = {}) { + return createHostNewSessions({ + projectTargetInfo: (id) => ENTRIES[id] ?? null, + claudeAccounts: () => ACCOUNTS, + ...over + }) +} + +describe('createHostNewSessions', () => { + const node = (over: Partial = {}): CanvasNodeState => ({ + id: 'saved-node', kind: 'terminal', title: 'Saved', position: { x: 0, y: 0 }, size: { width: 500, height: 300 }, + color: '#0a84ff', group: null, + cwd: '/repo/sub', agentId: 'claude', accountId: 'acct-ok', ...over + }) + + it('cold-restores saved local cwd, agent, account and indexed owner without a create hint', () => { + const r = resolver({ persistedCanvases: () => [{ id: 'entry-local', nodes: [node()] }] }) + expect(r.resolveNode?.('saved-node')).toEqual({ + cwd: '/repo/sub', agentId: 'claude', accountId: 'acct-ok', ownerProjectId: 'entry-local' + }) + expect(r.resolveNode?.('unknown')).toBeUndefined() + }) + + it('known ineligible and ambiguous nodes cannot be treated as unknown create targets', () => { + for (const nodes of [[node({ kind: 'sticky' })], [node({ ssh: { host: 'remote', user: 'u' } })], + [node({ sshRemoteTmux: true })], [node(), node()]]) { + expect(resolver({ persistedCanvases: () => [{ id: 'entry-local', nodes }] }).resolveNode?.('saved-node')).toBeNull() + } + for (const id of ['entry-ssh', 'entry-inline', 'unknown-project']) { + expect(resolver({ persistedCanvases: () => [{ id, nodes: [node()] }] }).resolveNode?.('saved-node')).toBeNull() + } + expect(resolver({ persistedCanvases: () => [ + { id: 'entry-local', nodes: [node()] }, { id: 'entry-ssh', nodes: [node()] } + ] }).resolveNode?.('saved-node')).toBeNull() + }) + + it('retains saved custom agents only while their configuration exists on this host', () => { + const deps = { persistedCanvases: () => [{ id: 'entry-local', nodes: [node({ agentId: 'custom:owned' })] }], + customAgents: () => [{ id: 'custom:owned', baseAgent: 'claude' }] } + const r = resolver(deps) + expect(r.resolveNode?.('saved-node')).toEqual({ cwd: '/repo/sub', agentId: 'custom:owned', + accountId: 'acct-ok', ownerProjectId: 'entry-local' }) + expect(resolver({ ...deps, customAgents: () => [] }).resolveNode?.('saved-node')).not.toHaveProperty('agentId') + // A wire create hint never acquires saved custom-agent execution privileges. + expect(r.resolve({ projectId: 'entry-local', agentId: 'custom:owned', accountId: 'acct-ok' })).not.toHaveProperty('agentId') + }) + it("owns the pane by the host's index ENTRY id, beside the project's folder", () => { + expect(resolver().resolve({ projectId: 'entry-local' })).toEqual({ + cwd: '/repo', + ownerProjectId: 'entry-local' + }) + }) + + it('carries a builtin agent, so the spawn gets that agent\'s hook env', () => { + for (const agentId of ['claude', 'codex', 'gemini', 'opencode', 'grok', 'copilot']) { + expect(resolver().resolve({ projectId: 'entry-local', agentId })?.agentId, agentId).toBe(agentId) + } + }) + + it('drops an agent id that is not a builtin — nothing from the wire reaches the env unchecked', () => { + for (const agentId of ['custom:abc', 'constructor', '__proto__', 'claude ', '']) { + const r = resolver().resolve({ projectId: 'entry-local', agentId }) + expect(r, agentId).not.toBeNull() + expect(r, agentId).not.toHaveProperty('agentId') + // The owner is the project's, whatever the agent field said. + expect(r?.ownerProjectId, agentId).toBe('entry-local') + } + }) + + it('resolves NOTHING — no owner, no folder — for a project this machine cannot create in', () => { + const r = resolver() + // Unknown id (e.g. a file id, or another machine's entry id): no owner can be claimed with it. + expect(r.resolve({ projectId: 'not-an-entry', agentId: 'claude' })).toBeNull() + // An SSH project's sessions run on its host, never here. + expect(r.resolve({ projectId: 'entry-ssh', agentId: 'claude' })).toBeNull() + // A cwd-less canvas has no folder to create the session in (the registrar refuses it too). + expect(r.resolve({ projectId: 'entry-inline', agentId: 'claude' })).toBeNull() + }) + + it('applies only a local, logged-in managed Claude account, and only to a Claude session', () => { + const r = resolver() + expect(r.resolve({ projectId: 'entry-local', agentId: 'claude', accountId: 'acct-ok' })?.accountId).toBe('acct-ok') + for (const accountId of ['acct-pending', 'acct-remote', 'acct-unknown']) { + expect(r.resolve({ projectId: 'entry-local', agentId: 'claude', accountId }), accountId).not.toHaveProperty( + 'accountId' + ) + } + expect(r.resolve({ projectId: 'entry-local', agentId: 'codex', accountId: 'acct-ok' })).not.toHaveProperty('accountId') + expect(r.resolve({ projectId: 'entry-local', accountId: 'acct-ok' })).not.toHaveProperty('accountId') + }) +}) diff --git a/src/main/remote/host-new-sessions.ts b/src/main/remote/host-new-sessions.ts new file mode 100644 index 0000000000..015b56528f --- /dev/null +++ b/src/main/remote/host-new-sessions.ts @@ -0,0 +1,70 @@ +// Where a session the PHONE starts is created, and who owns its pane (audits A33 + A72). +// +// The relay `pty.attach` of a fresh node id creates that node's tmux session. The phone names the +// project it is starting the session in (plus the account and agent it chose); everything the +// session is created WITH is resolved here, from this machine's own index and settings — never +// taken off the wire as-is: +// +// - `cwd`: the project's folder, from the index entry. Only a local folder project qualifies (the +// same set `projects.registerNode` accepts); an unknown, SSH or cwd-less project resolves to +// null and the attach creates exactly the bare session it created before this existed. +// - `accountId`: only a local, logged-in managed Claude account, and only for a Claude session. +// - `agentId`: only a builtin agent. It is what makes the spawn's hook env agent-aware +// (`hookServer.buildPtyEnv`: `NODETERM_AGENT_ID`, the hook-reply wait for Claude, the +// canvas-control grant `canControlCanvas` decides) — a session created without it keeps its +// bare env for life, because tmux reads `-e` only when a session is created. +// - `ownerProjectId` (A72): the pane-ownership ledger's owner (`agents/pane-ownership.ts`). It is +// the index ENTRY id the lookup just matched — the machine-local id the canvas passes as its own +// `ownerProjectId`, and the id agent messaging compares against — so a phone-started pane is +// provably owned by the project it was created in, exactly like a canvas-started one. A cloned +// project gets a fresh entry id, so this can never name a git-shared file id. +// +// Every value applies only when the attach CREATES the session (the relay host decides that from +// this machine's own `sessionExists` probe); a join changes nothing. + +import { BUILTIN_AGENT_IDS, type AgentId, type BuiltinAgentId } from '../../shared/agents/config' +import type { CanvasNodeState } from '../../shared/types' +import type { HostNewSessions } from './host-service' + +export interface HostNewSessionsDeps { + /** This machine's index, keyed by the machine-local entry id (`WorkspaceStore.projectTargetInfo`). */ + projectTargetInfo(projectId: string): { cwd?: string; ssh?: unknown } | null + /** This machine's managed Claude accounts (settings.json — hand-editable, so re-checked here). */ + claudeAccounts(): ReadonlyArray<{ id: string; pending?: boolean; host?: string }> + /** Saved host canvases, with portable node cwds resolved by WorkspaceStore. */ + persistedCanvases?(): ReadonlyArray<{ id: string; nodes: ReadonlyArray }> + /** Only saved custom agents may use this host's already configured custom-agent environment. */ + customAgents?(): ReadonlyArray<{ id: string; baseAgent?: string }> +} + +export function createHostNewSessions(deps: HostNewSessionsDeps): HostNewSessions { + const launch = (projectId: string, accountId?: string, agentId?: string, saved = false) => { + const info = deps.projectTargetInfo(projectId) + if (!info || info.ssh || !info.cwd) return null + const custom = saved && agentId?.startsWith('custom:') + ? deps.customAgents?.().find((a) => a.id === agentId) + : undefined + const agent = agentId && (BUILTIN_AGENT_IDS as readonly string[]).includes(agentId) + ? (agentId as BuiltinAgentId) + : custom ? (custom.id as AgentId) : undefined + const account = accountId && (agent === 'claude' || custom?.baseAgent === 'claude') && + deps.claudeAccounts().some((a) => a.id === accountId && !a.pending && !a.host) + ? accountId : undefined + return { cwd: info.cwd, ownerProjectId: projectId, + ...(account ? { accountId: account } : {}), ...(agent ? { agentId: agent } : {}) } + } + return { + resolve: ({ projectId, accountId, agentId }) => launch(projectId, accountId, agentId), + resolveNode: (nodeId) => { + const matches = deps.persistedCanvases?.().flatMap((p) => + p.nodes.filter((n) => n.id === nodeId).map((node) => ({ projectId: p.id, node }))) ?? [] + if (!matches.length) return undefined + // Ambiguous clones, nonterminals and standalone remote terminals cannot claim a local pane. + if (matches.length !== 1) return null + const { projectId, node } = matches[0] + if (node.kind !== 'terminal' || node.ssh || node.sshRemoteTmux) return null + const resolved = launch(projectId, node.accountId, node.agentId, true) + return resolved ? { ...resolved, cwd: node.cwd || resolved.cwd } : null + } + } +} diff --git a/src/main/remote/host-node-actions.test.ts b/src/main/remote/host-node-actions.test.ts index c485c56826..6b5eb96ed4 100644 --- a/src/main/remote/host-node-actions.test.ts +++ b/src/main/remote/host-node-actions.test.ts @@ -17,7 +17,8 @@ import { type HostFsOps, type HostNodeActions, type HostPtyManager, - type HostRelaySocket + type HostRelaySocket, + type HostRemoteNodes } from './host-service' function makeFakes(actions?: Partial & { delivered?: boolean }) { @@ -162,3 +163,202 @@ describe('delivery is the answer, never assumed', () => { ]) }) }) + +// Audit A12: quick answers used to go through a throwaway attach + write + kill, which dropped +// input that beat the async attach and killed the client before tmux read an ESC. `node.sendKeys` +// types through the node's existing session instead. +describe('node.sendKeys', () => { + const flush = (): Promise => new Promise((r) => setTimeout(r, 0)) + + it('types a short answer through the injected writer and reports it', async () => { + const sendKeys = vi.fn(async () => true) + const { handlers, responses } = makeFakes({ sendKeys }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'term-a-1', keys: '2' } }) + await flush() + expect(sendKeys).toHaveBeenCalledWith('term-a-1', '2') + expect(responses[0]).toEqual({ id: '1', ok: true, body: { sent: true } }) + }) + + it('accepts a lone Esc (Deny) and Enter', async () => { + const sendKeys = vi.fn(async () => true) + const { handlers, responses } = makeFakes({ sendKeys }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'n', keys: '\u001b' } }) + handlers.onRpc({ id: '2', method: 'node.sendKeys', params: { nodeId: 'n', keys: '\r' } }) + await flush() + expect(responses.map((r) => r.ok)).toEqual([true, true]) + }) + + it('answers {sent:false} — not an error — when nothing was delivered', async () => { + const { handlers, responses } = makeFakes({ sendKeys: async () => false }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'n', keys: '1' } }) + await flush() + expect(responses[0]).toEqual({ id: '1', ok: true, body: { sent: false } }) + }) + + it.each([ + ['an empty answer', ''], + ['a long input', 'x'.repeat(17)], + ['other control bytes', '\u0003'], + ['a newline', 'a\nb'] + ])('refuses %s before the writer is asked', async (_label, keys) => { + const sendKeys = vi.fn(async () => true) + const { handlers, responses } = makeFakes({ sendKeys }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'n', keys } }) + await flush() + expect(sendKeys).not.toHaveBeenCalled() + expect(responses[0].ok).toBe(false) + }) + + it('is "not served" without the injection', async () => { + const { handlers, responses } = makeFakes() + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'n', keys: '1' } }) + await flush() + expect(responses[0].ok).toBe(false) + expect((responses[0].body as { message: string }).message).toMatch(/not served/) + }) +}) + +// Follow-up to A12: a node of one of the desktop's SSH projects lives on ANOTHER host, and the +// local background write cannot reach it — so `node.sendKeys` answered `sent:false` and the phone +// opened the session for what is meant to be a one-tap answer. It is now typed over the project's +// ControlMaster, resolved the way `pty.attach` resolves such a node (audit A09), and never through +// the local writer — even when the master is down. +describe('node.sendKeys for a node of an SSH project', () => { + const flush = (): Promise => new Promise((r) => setTimeout(r, 0)) + const sshRemote = { + controlPath: '/tmp/cm.sock', + conn: { host: 'box', user: 'me' }, + remoteCwd: '~/repo' + } + + function makeRemoteFakes(opts: { + resolve: HostRemoteNodes['resolve'] + over?: HostPtyManager['backgroundWriteOver'] | null + }) { + const responses: Array<{ id: string; ok: boolean; body: unknown }> = [] + const socket: HostRelaySocket = { + respond: (id, ok, body) => responses.push({ id, ok, body }), + sendFrame: () => true + } + const fs: HostFsOps = { + listDir: async () => [], + readText: async () => '', + readBinary: async () => '', + writeText: async () => true + } + const over = opts.over === null ? undefined : vi.fn(opts.over ?? (async () => true)) + const pty = (over ? { backgroundWriteOver: over } : {}) as HostPtyManager + const localSendKeys = vi.fn(async () => true) + const nodeActions: HostNodeActions = { + wake: vi.fn(() => true), + refresh: vi.fn(() => true), + rename: vi.fn(() => true), + sendKeys: localSendKeys + } + const handlers = createHostHandlers( + pty, socket, fs, () => [], + undefined, undefined, undefined, undefined, undefined, undefined, + nodeActions, undefined, undefined, + { resolve: opts.resolve } + ) + return { handlers, responses, over, localSendKeys } + } + + it('types over the project’s master with the resolved sshRemote — never the local writer', async () => { + const { handlers, responses, over, localSendKeys } = makeRemoteFakes({ + resolve: (id) => (id === 'node-r' ? { where: 'me@box', sshRemote } : null) + }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'node-r', keys: '1' } }) + await flush() + expect(over).toHaveBeenCalledWith('node-r', '1', sshRemote) + expect(localSendKeys).not.toHaveBeenCalled() + expect(responses[0]).toEqual({ id: '1', ok: true, body: { sent: true } }) + }) + + it('carries a lone Esc (Deny) to the host as the same byte', async () => { + const { handlers, responses, over } = makeRemoteFakes({ resolve: () => ({ where: 'me@box', sshRemote }) }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'node-r', keys: '\u001b' } }) + await flush() + expect(over).toHaveBeenCalledWith('node-r', '\u001b', sshRemote) + expect(responses[0]).toEqual({ id: '1', ok: true, body: { sent: true } }) + }) + + it('answers {sent:false} when the host did not take the keys (no such session, master gone)', async () => { + const { handlers, responses } = makeRemoteFakes({ + resolve: () => ({ where: 'me@box', sshRemote }), + over: async () => false + }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'node-r', keys: '1' } }) + await flush() + expect(responses[0]).toEqual({ id: '1', ok: true, body: { sent: false } }) + }) + + it.each([ + ['rejects', async () => { + throw new Error('ssh died') + }], + ['throws before it returns a promise', () => { + throw new Error('no ssh') + }] + ])('answers {sent:false} when the remote write %s — the reply is never dropped', async (_label, over) => { + const { handlers, responses } = makeRemoteFakes({ + resolve: () => ({ where: 'me@box', sshRemote }), + over: over as HostPtyManager['backgroundWriteOver'] + }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'node-r', keys: '1' } }) + await flush() + expect(responses).toEqual([{ id: '1', ok: true, body: { sent: false } }]) + }) + + it('answers {sent:false} when the project is not connected — and falls back to NOTHING local', async () => { + const { handlers, responses, over, localSendKeys } = makeRemoteFakes({ resolve: () => ({ where: 'me@box' }) }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'node-r', keys: '1' } }) + await flush() + expect(responses[0]).toEqual({ id: '1', ok: true, body: { sent: false } }) + expect(over).not.toHaveBeenCalled() + expect(localSendKeys).not.toHaveBeenCalled() + }) + + it('answers {sent:false} when the pty manager cannot reach remote hosts', async () => { + const { handlers, responses, localSendKeys } = makeRemoteFakes({ + resolve: () => ({ where: 'me@box', sshRemote }), + over: null + }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'node-r', keys: '1' } }) + await flush() + expect(responses[0]).toEqual({ id: '1', ok: true, body: { sent: false } }) + expect(localSendKeys).not.toHaveBeenCalled() + }) + + it('a resolver that throws has not said the node is local: {sent:false}, no writer asked', async () => { + const { handlers, responses, over, localSendKeys } = makeRemoteFakes({ + resolve: () => { + throw new Error('index not loaded') + } + }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'node-r', keys: '1' } }) + await flush() + expect(responses[0]).toEqual({ id: '1', ok: true, body: { sent: false } }) + expect(over).not.toHaveBeenCalled() + expect(localSendKeys).not.toHaveBeenCalled() + }) + + it('a local node still goes through the local writer, exactly as before', async () => { + const { handlers, responses, over, localSendKeys } = makeRemoteFakes({ resolve: () => null }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'node-l', keys: '2' } }) + await flush() + expect(localSendKeys).toHaveBeenCalledWith('node-l', '2') + expect(over).not.toHaveBeenCalled() + expect(responses[0]).toEqual({ id: '1', ok: true, body: { sent: true } }) + }) + + it('still refuses an over-long answer before any resolver or writer is asked', async () => { + const resolve = vi.fn(() => ({ where: 'me@box', sshRemote })) + const { handlers, responses, over } = makeRemoteFakes({ resolve }) + handlers.onRpc({ id: '1', method: 'node.sendKeys', params: { nodeId: 'node-r', keys: 'x'.repeat(17) } }) + await flush() + expect(responses[0].ok).toBe(false) + expect(resolve).not.toHaveBeenCalled() + expect(over).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/remote/host-service.chat.test.ts b/src/main/remote/host-service.chat.test.ts index a05590e380..d743a43bae 100644 --- a/src/main/remote/host-service.chat.test.ts +++ b/src/main/remote/host-service.chat.test.ts @@ -65,6 +65,10 @@ function make(chat?: Partial, served = true) { undefined, undefined, undefined, + undefined, + undefined, + undefined, + undefined, served ? ops : undefined ) const call = async (method: string, params: unknown) => { diff --git a/src/main/remote/host-service.decision-hold.test.ts b/src/main/remote/host-service.decision-hold.test.ts new file mode 100644 index 0000000000..b5a2c674eb --- /dev/null +++ b/src/main/remote/host-service.decision-hold.test.ts @@ -0,0 +1,199 @@ +// connectHostSession holds a freshly bridged peer's requests while an async `onPeerReady` decides +// without the human (review of A07-late). The standing host's decision reads its pin store and, for +// a paired phone whose key it has not pinned yet, reads agent.json and writes the pin; the phone's +// first request can land one relay round trip after the handshake, inside that. Answered "Awaiting +// host approval.", a background check (Android RelayConnector, requireApproved) gave up and forgot +// the approval this computer was in the middle of giving. The Android interop suite checks the same +// thing against a real phone client (RelayInteropTest, FIXTURE_DECIDE_AFTER_MS). +// +// electron and the relay socket are mocked; what is under test is the gate in connectHostSession. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RelaySocket, RpcRequest } from './relay-socket' + +vi.mock('electron', () => ({ + app: { isPackaged: true, getPath: () => '/tmp/nodeterm-decision-hold-test' }, + safeStorage: { isEncryptionAvailable: () => false }, + ipcMain: { on: () => {}, handle: () => {} } +})) +vi.mock('../../core/pty-manager', () => ({ PtyManager: class {} })) +vi.mock('../../core/license', () => ({ isPremium: () => true, getStoredEntitlement: () => null })) +vi.mock('./host-canvas-hub', () => ({ + initHostCanvasHub: () => {}, + currentCanvas: () => null, + subscribeCanvas: () => () => {} +})) + +interface Answer { + id: string + ok: boolean + body: unknown +} +let relay: { onReady(): void; onRpc(req: RpcRequest): void; onClose(): void } | null = null +const answers: Answer[] = [] +let closed = 0 + +vi.mock('./relay-socket', () => ({ + connectRelay: (opts: { onReady(): void; onRpc(req: RpcRequest): void; onClose(): void }): RelaySocket => { + relay = opts + return { + respond: (id: string, ok: boolean, body: unknown) => answers.push({ id, ok, body }), + sendFrame: () => true, + notify: () => true, + sas: () => '12345', + peerPublicKeyB64: () => 'phone-pub', + close: () => { + closed += 1 + } + } as unknown as RelaySocket + } +})) + +import { + connectHostSession, + PEER_DECISION_HOLD_MAX, + PEER_DECISION_HOLD_MS, + type HostSession, + type HostSessionOptions +} from './host-service' + +const AWAITING = { message: 'Awaiting host approval.' } + +function open(onPeerReady: HostSessionOptions['onPeerReady']): HostSession { + return connectHostSession({ + url: 'wss://relay.test', + token: 'tok', + ourKeys: { publicKey: new Uint8Array(32), secretKey: new Uint8Array(32) }, + pty: {} as HostSessionOptions['pty'], + getLatestCanvas: () => null, + subscribeCanvas: () => () => {}, + applyMutation: () => {}, + listProjects: async () => 'BLOB', + onPeerReady, + onClose: () => {} + }) +} + +function ask(id: string): void { + relay!.onRpc({ id, method: 'projects.list', params: undefined }) +} + +/** Let the handlers' own promise chain (listProjects → respond) run. */ +async function settle(): Promise { + for (let i = 0; i < 12; i++) await Promise.resolve() +} + +function deferred(): { promise: Promise; resolve(): void; reject(err: unknown): void } { + let resolve!: () => void + let reject!: (err: unknown) => void + const promise = new Promise((res, rej) => ((resolve = res), (reject = rej))) + return { promise, resolve, reject } +} + +beforeEach(() => { + relay = null + answers.length = 0 + closed = 0 +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('requests that arrive while the host decides without the human (review of A07-late)', () => { + it('are held, and served once the decision approves the peer', async () => { + const decision = deferred() + const session = open(() => decision.promise) + relay!.onReady() + ask('1') + ask('2') + await settle() + expect(answers).toEqual([]) // not "Awaiting host approval.": the host has not decided yet + session.approve() + await settle() + expect(answers).toEqual([ + { id: '1', ok: true, body: { output: 'BLOB' } }, + { id: '2', ok: true, body: { output: 'BLOB' } } + ]) + decision.resolve() + await settle() + ask('3') // after the decision: served as before + await settle() + expect(answers.at(-1)).toEqual({ id: '3', ok: true, body: { output: 'BLOB' } }) + }) + + it('are answered "Awaiting host approval." once the decision hands the peer to the human', async () => { + const decision = deferred() + open(() => decision.promise) + relay!.onReady() + ask('1') + await settle() + expect(answers).toEqual([]) + decision.resolve() // the dialog was raised: a human decides from here, so nothing is held + await settle() + expect(answers).toEqual([{ id: '1', ok: false, body: AWAITING }]) + ask('2') + await settle() + expect(answers.at(-1)).toEqual({ id: '2', ok: false, body: AWAITING }) + }) + + it('a decision that fails answers as one that went to the human', async () => { + const decision = deferred() + open(() => decision.promise) + relay!.onReady() + ask('1') + decision.reject(new Error('fixture')) + await settle() + expect(answers).toEqual([{ id: '1', ok: false, body: AWAITING }]) + }) + + it('a synchronous decision holds nothing (the interactive host)', async () => { + open(() => {}) + relay!.onReady() + ask('1') + await settle() + expect(answers).toEqual([{ id: '1', ok: false, body: AWAITING }]) + }) + + it('an approval made inside the call holds nothing, even when the decision is still a promise', async () => { + const decision = deferred() + open((s) => { + s.approve() + return decision.promise + }) + relay!.onReady() + ask('1') + await settle() + expect(answers).toEqual([{ id: '1', ok: true, body: { output: 'BLOB' } }]) + }) + + it('never holds longer than PEER_DECISION_HOLD_MS, or more than PEER_DECISION_HOLD_MAX requests', async () => { + vi.useFakeTimers() + open(() => new Promise(() => {})) // a decision stuck on a hung disk + relay!.onReady() + for (let i = 0; i <= PEER_DECISION_HOLD_MAX; i++) ask(String(i)) + await settle() + // The one past the limit is answered at once; the rest wait for the decision or the deadline. + expect(answers).toEqual([{ id: String(PEER_DECISION_HOLD_MAX), ok: false, body: AWAITING }]) + vi.advanceTimersByTime(PEER_DECISION_HOLD_MS - 1) + await settle() + expect(answers).toHaveLength(1) + vi.advanceTimersByTime(1) + await settle() + expect(answers).toHaveLength(PEER_DECISION_HOLD_MAX + 1) + expect(answers.slice(1).map((a) => a.id)).toEqual(Array.from({ length: PEER_DECISION_HOLD_MAX }, (_, i) => String(i))) + expect(answers.every((a) => !a.ok)).toBe(true) + }) + + it('a session torn down while deciding answers nothing it held', async () => { + const decision = deferred() + const session = open(() => decision.promise) + relay!.onReady() + ask('1') + session.close() + decision.resolve() + await settle() + expect(closed).toBe(1) + expect(answers).toEqual([]) + }) +}) diff --git a/src/main/remote/host-service.legacy-proof.test.ts b/src/main/remote/host-service.legacy-proof.test.ts new file mode 100644 index 0000000000..82bff2f5f3 --- /dev/null +++ b/src/main/remote/host-service.legacy-proof.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RelaySocket, RpcRequest } from './relay-socket' +import type { LegacyRelayPairings } from './relay-pairing-proof' +vi.mock('electron', () => ({ app: { isPackaged: true, getPath: () => '/tmp/unused-legacy-fixture' }, ipcMain: { on() {}, handle() {} } })) +vi.mock('../../core/pty-manager', () => ({ PtyManager: class {} })) +vi.mock('../../core/license', () => ({ isPremium: () => true, getStoredEntitlement: () => null })) +vi.mock('./host-canvas-hub', () => ({ initHostCanvasHub() {}, currentCanvas: () => null, subscribeCanvas: () => () => {} })) +let relay: { onReady(): void; onRpc(req: RpcRequest): void; onClose(): void } +const answers: { id: string; ok: boolean; body: unknown }[] = [] +vi.mock('./relay-socket', () => ({ connectRelay: (opts: typeof relay) => { + relay = opts; return { respond: (id, ok, body) => answers.push({ id, ok, body }), notify: () => true, + sendFrame: () => true, sas: () => '12345', peerPublicKeyB64: () => Buffer.alloc(32, 2).toString('base64'), close() {}, + rpc: async () => null, sendTunnelText: () => false, sendTunnelBinary: () => false, bufferedAmount: () => 0 } satisfies RelaySocket +} })) +import { connectHostSession, type HostSessionOptions } from './host-service' +const id = '11111111-1111-4111-8111-111111111111' +const settle = async () => { for (let n = 0; n < 12; n++) await Promise.resolve() } +function open(pairings?: LegacyRelayPairings) { + return connectHostSession({ url: 'wss://fixture', token: 'inert', ourKeys: { publicKey: new Uint8Array(32), secretKey: new Uint8Array(32) }, + pty: {} as HostSessionOptions['pty'], getLatestCanvas: () => null, subscribeCanvas: () => () => {}, applyMutation() {}, + onPeerReady() {}, onClose() {}, legacyRelayPairings: pairings }) +} +const ask = (requestId: string, method = 'pairing.relayKeyChallengeV1') => relay.onRpc({ id: requestId, method, params: { pairingId: id } }) +const store = (): LegacyRelayPairings => ({ inspect: vi.fn(async () => ({ status: 'unprovable' })), + associate: vi.fn(async () => ({ status: 'associated' })) }) +beforeEach(() => { answers.length = 0 }) +describe('approved-only pairing proof wiring', () => { + it('rejects both verbs before approval without consulting pairing credentials', async () => { + const pairings = store(); open(pairings); relay.onReady(); ask('challenge'); ask('proof', 'pairing.relayKeyProofV1'); await settle() + expect(answers).toEqual([{ id: 'challenge', ok: false, body: { message: 'Awaiting host approval.' } }, { id: 'proof', ok: false, body: { message: 'Awaiting host approval.' } }]) + expect(pairings.inspect).not.toHaveBeenCalled(); expect(pairings.associate).not.toHaveBeenCalled() + }) + it('serves only the exact approved session and ignores allocation notifications', async () => { + const pairings = store(), session = open(pairings); relay.onReady(); session.approve() + ask(''); ask('read'); await settle() + expect(pairings.inspect).toHaveBeenCalledTimes(1); expect(answers).toEqual([{ id: 'read', ok: true, body: { status: 'unprovable' } }]) + }) + it('cannot be reapproved after socket closure or intentional teardown', async () => { + for (const path of ['socket', 'intentional']) { + const pairings = store(), session = open(pairings); relay.onReady(); session.approve() + if (path === 'socket') relay.onClose(); else session.close() + session.approve(); ask(path); await settle(); expect(pairings.inspect).not.toHaveBeenCalled(); expect(session.isApproved()).toBe(false) + } + }) + it('drops a late result rather than answering on a retired connection', async () => { + const pairings = store(); let release!: () => void + vi.mocked(pairings.inspect).mockImplementationOnce(async () => { await new Promise(r => { release = r }); return { status: 'unprovable' } }) + const session = open(pairings); relay.onReady(); session.approve(); ask('late'); session.close(); release(); await settle() + expect(answers).toEqual([]) + }) + it('older and non-standing host contexts return unsupported after approval', async () => { + const session = open(); relay.onReady(); session.approve(); ask('missing'); await settle() + expect(answers[0]).toEqual({ id: 'missing', ok: false, body: { message: 'Relay pairing proof is not served on this host.' } }) + }) +}) diff --git a/src/main/remote/host-service.test.ts b/src/main/remote/host-service.test.ts new file mode 100644 index 0000000000..20d4b3ae5f --- /dev/null +++ b/src/main/remote/host-service.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { createHostHandlers, type HostFsOps, type HostPtyManager, type HostRelaySocket } from './host-service' +import { OP } from './framing' + +// This suite exercises the real host router with a fake PTY boundary, not Electron's factory. +vi.mock('../../core/pty-manager', () => ({ PtyManager: class {} })) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((ok, fail) => { resolve = ok; reject = fail }) + return { promise, resolve, reject } +} + +function pendingHost() { + const commits = [deferred(), deferred()] + const responses: Array<{ id: string; ok: boolean; body: unknown }> = [] + const frames: number[] = [] + let attachCount = 0 + const pty = { + createDetached: vi.fn(() => 'unused'), + attachDetached: vi.fn(() => 'unused'), + captureSnapshot: vi.fn(async () => ''), + sessionExists: vi.fn(async () => true), + prepareRelayAttach: vi.fn(async () => ({ + kind: 'ready' as const, remote: true, + sessionExists: async () => true, + snapshot: async () => 'existing screen', + attach: vi.fn(() => commits[attachCount++].promise) + })), + write: vi.fn(), resize: vi.fn(), setFlow: vi.fn(), kill: vi.fn() + } satisfies HostPtyManager + const socket: HostRelaySocket = { + respond: (id, ok, body) => { responses.push({ id, ok, body }) }, + sendFrame: (op) => { frames.push(op); return true } + } + const handlers = createHostHandlers(pty, socket, {} as HostFsOps, () => [], + undefined, () => 17) + const attach = (id: string) => handlers.onRpc({ id, method: 'pty.attach', params: { nodeId: id } }) + const input = (streamId: number, text: string) => handlers.onFrame({ op: OP.Input, streamId, seq: 0, + payload: new TextEncoder().encode(text) }) + return { pty, handlers, responses, frames, commits, attach, input } +} + +describe('relay attach input handoff', () => { + it('retains the first launch input exactly once until the original async attach commits', async () => { + const h = pendingHost() + h.attach('original') + await vi.waitFor(() => expect(h.frames).toContain(OP.SnapshotEnd)) + h.input(1, 'claude --resume original\r') + h.input(1, 'next') + expect(h.pty.write).not.toHaveBeenCalled() + h.commits[0].resolve('original-session') + await vi.waitFor(() => expect(h.pty.write).toHaveBeenCalledTimes(2)) + expect(h.pty.write.mock.calls).toEqual([ + [17, 'original-session', 'claude --resume original\r'], [17, 'original-session', 'next'] + ]) + h.input(1, 'live') + expect(h.pty.write.mock.calls.at(-1)).toEqual([17, 'original-session', 'live']) + }) + + it('never flushes a closed stream into its replacement or adopts its late client', async () => { + const h = pendingHost() + h.attach('original') + await vi.waitFor(() => expect(h.responses).toHaveLength(1)) + h.input(1, 'old launch\r') + h.handlers.closeAll() + h.attach('replacement') + await vi.waitFor(() => expect(h.responses).toHaveLength(2)) + h.input(1, 'late old input') + h.input(2, 'new launch\r') + h.commits[0].resolve('old-client') + h.commits[1].resolve('new-client') + await vi.waitFor(() => expect(h.pty.write).toHaveBeenCalledTimes(1)) + expect(h.pty.write.mock.calls).toEqual([[17, 'new-client', 'new launch\r']]) + expect(h.pty.kill).toHaveBeenCalledWith(null, 'old-client') + }) + + it('fails an overflowing stream without input and releases its eventual client', async () => { + const h = pendingHost() + h.attach('original') + await vi.waitFor(() => expect(h.responses).toHaveLength(1)) + h.input(1, 'x'.repeat(64 * 1024)) + h.input(1, 'overflow') + expect(h.frames).toContain(OP.Error) + h.commits[0].resolve('overflow-client') + await vi.waitFor(() => expect(h.pty.kill).toHaveBeenCalledWith(null, 'overflow-client')) + expect(h.pty.write).not.toHaveBeenCalled() + }) + + it('discards retained input when the original attach fails', async () => { + const h = pendingHost() + h.attach('original') + await vi.waitFor(() => expect(h.responses).toHaveLength(1)) + h.input(1, 'never replay\r') + h.commits[0].reject(new Error('master gone')) + await vi.waitFor(() => expect(h.frames).toContain(OP.Error)) + h.input(1, 'late') + expect(h.pty.write).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/remote/host-service.ts b/src/main/remote/host-service.ts index 02c27b7b34..d9981b8d49 100644 --- a/src/main/remote/host-service.ts +++ b/src/main/remote/host-service.ts @@ -28,13 +28,17 @@ import path from 'path' import { app, ipcMain, type BrowserWindow } from 'electron' import { IPC } from '../../shared/ipc' import { REF_MAX_LEN } from '../../shared/presence' +import { validHistoryQuery, type HistorySearch } from '../../core/terminal-history' +import { HistoryScrollView } from '../../core/history-scroll-view' +import type { NativeScrollResult } from '../../shared/history-scroll' +import { COMPOSED_INPUT_UNCERTAIN, COMPOSED_INPUT_UNSUPPORTED, parseComposedInput, type ComposedInput, type ComposedInputResult } from '../../shared/composed-input' import type { CanvasMutation, CanvasState, DirEntry, KanbanColumn, KanbanLabel, PtyCreateOptions } from '../../shared/types' import type { AgentId } from '../../shared/agents/config' import { PtyManager, type DetachedSinks, type RelayAttachPrep } from '../../core/pty-manager' import * as fsOps from '../../core/fs-ops' import { TITLE_MAX, type RemoteNodeInput } from '../../core/project-node-append' import { parseCardLabelEdit, type CardLabelEdit } from '../../core/project-kanban-write' -import { isValidPendingId } from '../../core/agents/pending-approvals' +import { isValidPendingId, type PendingAnswerResult } from '../../core/agents/pending-approvals' import { GROK_AMBIGUOUS_SESSION_MESSAGE, isGrokAmbiguousSessionError, normalizeChatPage } from '../../shared/chat-page' import { CHAT_SEND_TEXT_MAX, @@ -52,6 +56,9 @@ import { sanitizeClientMutation } from './canvas-sync' import { connectRelay, type RelaySocket, type RpcRequest } from './relay-socket' import { initHostCanvasHub, currentCanvas, subscribeCanvas } from './host-canvas-hub' import { createPhonePresence, type PhonePresence } from './phone-presence' +import type { HostLanReport } from './host-lan-report' +import { createRelayPairingProof, type LegacyRelayPairings } from './relay-pairing-proof' +import { RELAY_PAIRING_CHALLENGE_METHOD, RELAY_PAIRING_PROOF_METHOD } from '../../shared/relay-pairing-proof' import { registerPeerSessionKiller } from './peer-revoke' // Default relay endpoint; `NODETERM_RELAY_URL` overrides it (mirrors license.ts's API_BASE / @@ -72,17 +79,51 @@ const FRESH_PROBE_BUDGET_MS = 750 // The slice of pty-manager the host needs. PtyManager satisfies this; tests pass a fake. export interface HostPtyManager { + /** Mode-aware movement on this exact attached viewer; absent means no safe wheel route. */ + scrollAttached?(clientId: number | null, sessionId: string, up: boolean, lines: number, + capture: boolean, current: () => boolean): Promise + /** Explicit composer action on this exact attached viewer. Absent on older implementations. */ + submitComposed?(sessionId: string, input: ComposedInput, current: () => boolean): Promise + /** Search all retained output of this attached generation. Absent on older hosts. */ + historySearch?(sessionId: string, query: string): Promise createDetached(options: PtyCreateOptions, sinks: DetachedSinks): string + /** Attach a relay-served PTY to the EXISTING tmux session for a node id (create if absent). */ + attachDetached( + persistKey: string, + sinks: DetachedSinks, + options?: Omit + ): string + /** Resolve bounded project settings before the phone can deliver its cold launch line. + * The commit stays synchronous: no input may fall between SnapshotEnd and live attachment. */ + prepareDetachedAttach?( + persistKey: string, + options: Omit + ): Promise<{ readonly fresh: boolean; attach(sinks: DetachedSinks): string }> + /** Current visible screen of a node's tmux session, for the attach snapshot. */ + captureSnapshot(persistKey: string): Promise + /** Does a tmux session for this node id exist RIGHT NOW? Asked before `attachDetached`, which + * CREATES one when it doesn't — so the client can tell a warm join from a cold start. */ /** Decide where (and with what env) a relay attach for a node id runs, from this machine's own * records — see `PtyManager.prepareRelayAttach`. Replaces the bare local `attachDetached`, which * created a LOCAL session for a remote node. */ - prepareRelayAttach( + prepareRelayAttach?( nodeId: string, size: { cols: number; rows: number }, - hint?: { projectId?: string } + hint?: { projectId?: string; create?: Pick } ): Promise /** Does a session for this node id exist RIGHT NOW? (The destroy path's outcome check.) */ sessionExists(persistKey: string): Promise + /** `sessionExists` / `captureSnapshot` for a node on an SSH project's host (see HostRemoteNodes). + * Optional: absent ⇒ remote nodes are refused rather than attached locally. */ + sessionExistsOver?(persistKey: string, sshRemote: NonNullable): Promise + captureSnapshotOver?(persistKey: string, sshRemote: NonNullable): Promise + /** `node.sendKeys` for a node on an SSH project's host: type the keys into its REMOTE pane over + * the master. Optional: absent ⇒ such a node answers `sent:false`, never a local write. */ + backgroundWriteOver?( + persistKey: string, + data: string, + sshRemote: NonNullable + ): Promise /** `clientId` identifies WHO typed (the bridged phone's presence peer), so the keystroke can be * attributed to it — null when this session has no peer, which just means it is not badged. */ write(clientId: number | null, sessionId: string, data: string): void @@ -155,8 +196,23 @@ export interface HostNodeActions { refresh(nodeId: string): boolean /** Rename a node through the renderer's `renameSession` funnel (title pre-sanitized here). */ rename(nodeId: string, title: string): boolean + /** + * Type a short quick answer (`node.sendKeys`) into the node's session without attaching a new + * client. Resolves whether it was delivered. Optional: absent ⇒ "not served", and the phone falls + * back to its old attach-and-write. LOCAL nodes only: a node `HostRemoteNodes` places on an SSH + * host is typed over its master (`HostPtyManager.backgroundWriteOver`) and never reaches this. + */ + sendKeys?(nodeId: string, keys: string): Promise } +/** + * What `node.sendKeys` accepts: a SHORT quick answer — a digit or a few printable characters, ESC, + * Enter. It exists for the Inbox's question/approval shortcuts, not as a general input channel + * (`pty.attach` is that), so anything longer or carrying other control bytes is refused. + */ +// eslint-disable-next-line no-control-regex -- the allowed controls are exactly ESC and CR +export const SEND_KEYS_RE = /^[\x1b\r\x20-\x7e]{1,16}$/ + /** * The kanban board writes the phone may ask this host to make on its behalf (`projects.ensureBoard` * / `projects.setCardColumn`). Both land in `WorkspaceStore`, which owns the read-modify-write and @@ -188,6 +244,72 @@ export interface HostKanbanOps { ): Promise<{ edited: boolean; labels: KanbanLabel[]; cardLabelIds: string[] } | null> } +/** + * The phone's Inbox actions that need the DESKTOP rather than the pane (`approvals.answer` / + * `inbox.ack`). A phone on direct SSH does both by writing files on the host (the hook-reply + * `~/.nodeterm/pending/.answer`, the `~/.nodeterm/acks/.seen` read-ack); a phone on the + * relay cannot — `fs.*` is jailed to the project roots, and rightly so — so without these verbs a + * relay-only phone (every Windows host, and any phone off the LAN) could neither answer a held + * approval nor tell the desktop it read a finished session. Typing `1` instead is NOT a substitute: + * while the hook holds the request the prompt is not on screen yet, so the keystroke would land in + * the agent's composer. + * + * Both land on the SAME functions the desktop's own surfaces use (the canvas Approve/Deny button's + * `agent:answer-permission` handler; the ack sweeper's `ackDone` + unread-clear), so a relay answer + * cannot drift from a local one. Absent ⇒ the verbs answer an honest "not served". + */ +export interface HostInboxOps { + /** + * Answer a held permission hook. `sent` = the answer reached a hold that still exists; `gone` = + * the hold had already ended (the hook timed out, or another surface answered) so nothing was + * written; `failed` = the write could not happen. See `PendingAnswerResult`. + */ + answerPermission(nodeId: string, pendingId: string, decision: 'allow' | 'deny' | 'allow-always', suggestionIndex?: number): Promise + /** v2 held AskUserQuestion: indexes for every question, validated against the live request. */ + answerQuestion?(nodeId: string, pendingId: string, selections: number[][]): Promise + /** The phone READ a finished session: resolve its done event(s) and clear the desktop unread. */ + ackRead(nodeId: string): void +} + +/** + * Nodes of the desktop's SSH projects live on ANOTHER machine: their tmux session is on that host, + * reached over the project's ControlMaster. A relay `pty.attach` used to attach them to the + * desktop's LOCAL tmux, which created an empty phantom `nt-` here and had the phone offer to + * resume the agent on the wrong machine (audit A09) — against the rule that a remote node is never + * spawned locally. With this injected, such a node is attached over its master or refused. + */ +export interface HostRemoteNodes { + /** + * `null` = a local node. Otherwise the node's host (`user@host`, for the refusal text) and, when + * the project's ControlMaster is connected and its setup finished, the `sshRemote` to attach over. + */ + resolve(nodeId: string): { where: string; sshRemote?: NonNullable } | null +} + +/** + * Where a session the PHONE starts is created (audit A33). `pty.attach` of a fresh node id creates + * its tmux session; the phone could only steer it through the launch line (`cd '' &&`, + * `CLAUDE_CONFIG_DIR=…`), which is POSIX shell — on a Windows host the session started in the home + * folder and silently dropped the account. The phone now names the project (and account/agent) on + * the attach, and the HOST resolves them from its own registry and settings, applied only when the + * attach creates the session. Everything is validated here; anything unknown is simply not applied. + * + * The agent id is what gives the created session its agent-specific hook env, and + * `ownerProjectId` — the index entry id, resolved by the host, never the phone's string unchecked — + * records the pane's owner the way a canvas-started spawn does (audit A72). Production: + * `createHostNewSessions` (host-new-sessions.ts). + */ +export interface HostNewSessions { + resolve(req: { + projectId: string + accountId?: string + agentId?: string + }): Pick | null + /** Saved host facts win over create hints. Undefined means unknown; null means known but + * ineligible, so a caller cannot replace that node's owner with a different project hint. */ + resolveNode?(nodeId: string): ReturnType | undefined +} + /** * The phone's Chat screen (docs/mobile-chat-view.md §3.2): `chat.page` / `chat.status` / * `chat.send` / `agent.answer`. The phone sends ONLY a node id (plus paging / text / answer); @@ -222,6 +344,13 @@ interface Stream { seq: number /** True while the PTY is paused due to relay backpressure. */ paused: boolean + history: HistoryScrollView + scrollTail: Promise + pendingScroll: number + /** Exact-stream input held only while the remote attach commit is still in flight. */ + pendingInput: Array<{ clientId: number | null; data: string }> + pendingInputBytes: number + pendingSize?: { cols: number; rows: number } } function asRecord(value: unknown): Record { @@ -298,6 +427,17 @@ export function createHostHandlers( // Kanban board writes on the phone's behalf (`projects.ensureBoard` / `projects.setCardColumn`). // Absent ⇒ the verbs answer an honest "not served". kanban?: HostKanbanOps, + // Inbox actions (`approvals.answer` / `inbox.ack`). Absent ⇒ the verbs answer "not served". + inbox?: HostInboxOps, + // Which nodes belong to an SSH project, and how to reach their host (see HostRemoteNodes). + // Absent ⇒ every node is attached locally, as before (the Server Edition has no SSH projects). + remoteNodes?: HostRemoteNodes, + // Where a phone-started session is created (see HostNewSessions). Absent ⇒ `{cols, rows}` only. + newSessions?: HostNewSessions, + // This computer's current LAN address and SSH host keys, sent as `lan` next to every `projects.list` + // answer (audit A74-refresh, host-lan-report.ts): the phone refreshes the LAN leg it dials from a + // relay-authenticated answer. Absent, or answering null ⇒ no `lan` field, the reply as before. + lanReport?: () => Promise, // The phone's Chat screen (`chat.page` / `chat.status` / `chat.send` / `agent.answer`). // Absent ⇒ the verbs answer an honest "not served". chat?: HostChatOps @@ -313,6 +453,10 @@ export function createHostHandlers( // Report AFTER the delete: `detached` may consult the live viewer set via its own bookkeeping, // and a callback that throws must not leave the stream registered. if (stream) { + stream.history.clear() + stream.pendingInput = [] + stream.pendingInputBytes = 0 + stream.pendingSize = undefined try { remoteViewer?.detached(stream.persistKey) } catch { @@ -404,12 +548,54 @@ export function createHostHandlers( const cols = Math.max(1, num(p.cols, 80)) const rows = Math.max(1, num(p.rows, 24)) + // A node of an SSH project: attach it over that project's ControlMaster, or refuse — never a + // local session (audit A09). Resolved before a stream is reserved, so a refusal leaves nothing. + let remote: NonNullable | undefined + let owner: ReturnType = null + try { + owner = remoteNodes?.resolve(nodeId) ?? null + } catch { + owner = null + } + if (owner) { + if (!owner.sshRemote || !pty.sessionExistsOver || !pty.captureSnapshotOver) { + socket.respond(req.id, false, { + message: + `This session runs on ${owner.where}, and your computer is not connected to it right now. ` + + 'Open that project in nodeterm on the computer, then try again.' + }) + return + } + remote = owner.sshRemote + } + + // A phone starting a NEW session names its project (and account/agent): the host resolves the + // folder, account, agent and pane owner itself (A33, A72). Resolved now, applied only if this + // attach creates the session — `created` below, from this machine's own `sessionExists` probe, + // which fails toward "exists". That gate is what keeps a JOIN from claiming a pane's ownership + // (`agents/pane-ownership.ts`: ownership is recorded on a genuine fresh spawn only). + let create: Pick | null = null + const projectId = str(p.projectId) + if (!remote && newSessions) { + try { + const saved = newSessions.resolveNode?.(nodeId) + create = saved === undefined + ? projectId && projectId.length <= REF_MAX_LEN + ? newSessions.resolve({ projectId, accountId: str(p.accountId), agentId: str(p.agentId) }) + : null + : saved + } catch { + create = null + } + } const streamId = ++streamCounter - const stream: Stream = { sessionId: '', persistKey: nodeId, seq: 0, paused: false } + const stream: Stream = { sessionId: '', persistKey: nodeId, seq: 0, paused: false, + history: new HistoryScrollView(), scrollTail: Promise.resolve(), pendingScroll: 0, + pendingInput: [], pendingInputBytes: 0 } const sinks = makeSinks(streamId, stream, p.resizedFrames === true) - // Reserve the stream, then respond so the client can route Input/Resize frames; the snapshot - // + live attach then proceed. Capturing the screen is async (a tmux side-call). + // Reserve while the capture/settings preparation is async. Respond, snapshot and live attach + // commit together afterwards, so the client cannot send its launch into an unready session. streams.set(streamId, stream) // A phone viewer is now watching this node's session (reported at RESERVE time, balanced by // `dropStream`): the desktop wakes a hibernated node for it and shields it from Eco. @@ -419,67 +605,83 @@ export function createHostHandlers( /* viewer bookkeeping must never break the attach */ } - // WHERE this attach runs, and with what env, is decided by core from this machine's own - // records (`PtyManager.prepareRelayAttach`): a node of an SSH project runs over that project's - // ControlMaster or is REFUSED — never a local `nt-` wearing a remote node's identity — and a - // local node gets the env the desktop would give it. The phone's optional `projectId` can only - // choose among those records or refuse; it never routes a node anywhere on its own. - const projectHint = str(p.projectId) + // Core resolves saved local/SSH records. Only the host's validated creation fallback may + // supply facts for an unregistered local node; saved facts and remote refusal always win. void (async () => { let prep: RelayAttachPrep try { - prep = await pty.prepareRelayAttach(nodeId, { cols, rows }, { projectId: projectHint }) + if (pty.prepareRelayAttach) { + prep = await pty.prepareRelayAttach(nodeId, { cols, rows }, { + projectId: str(p.projectId), ...(create ? { create } : {}) + }) + } else { + // Compatibility for older managers: retain Android's bounded preparation and explicit + // remote-only routing, with no unchecked phone paths or launch facts. + const existed = await Promise.race([ + (remote ? pty.sessionExistsOver!(nodeId, remote) : pty.sessionExists(nodeId)).catch(() => true), + new Promise((r) => setTimeout(() => r(true), FRESH_PROBE_BUDGET_MS)) + ]) + const options = remote + ? { cols, rows, sshRemote: remote, requireRemote: true } + : { cols, rows, ...((pty.prepareDetachedAttach || !existed) && create ? create : {}) } + const prepared = pty.prepareDetachedAttach + ? await pty.prepareDetachedAttach(nodeId, options) + : { fresh: !existed, attach: (s: DetachedSinks) => pty.attachDetached(nodeId, s, options) } + prep = { + kind: 'ready', remote: !!remote, + get fresh() { return prepared.fresh }, + sessionExists: () => remote ? pty.sessionExistsOver!(nodeId, remote) : pty.sessionExists(nodeId), + snapshot: () => remote ? pty.captureSnapshotOver!(nodeId, remote) : pty.captureSnapshot(nodeId), + attach: async (s) => prepared.attach(s) + } + } } catch { prep = { kind: 'refused', reason: 'not-connected', message: 'Could not prepare this session.' } } + if (streams.get(streamId) !== stream) return if (prep.kind === 'refused') { - // Nothing was spawned and no streamId was ever handed out: release the reservation and - // answer with the reason, so the phone shows a sentence instead of a bare shell. dropStream(streamId) socket.respond(req.id, false, { message: prep.message, reason: prep.reason }) return } - // `fresh` — did this attach CREATE the session, or join a live one? It has to be asked BEFORE - // `attach`, whose `tmux new-session -A` creates when the session is gone; afterwards it always - // exists and the answer is meaningless. Without it a mirrored client could not tell "I joined - // your running agent" from "I just made you an empty login shell", which is what put a bare - // `~ %` prompt under a Claude node's title on the phone once the host's tmux server had died. - // - // Bounded, and fail-safe toward "warm": a probe that is slow or unprobeable answers `false`, - // so a client that cold-restores on `fresh` types nothing into a session that may be live. - // The bound matters because this precedes the RPC response and a probe can sit on its - // timeout when tmux (or, for a remote node, the master) is wedged. const existed = await Promise.race([ prep.sessionExists().catch(() => true), new Promise((r) => setTimeout(() => r(true), FRESH_PROBE_BUDGET_MS)) ]) - socket.respond(req.id, true, { streamId, fresh: !existed }) const snapshot = await prep.snapshot().catch(() => '') - // The stream may have been killed/closed while the capture was in flight. - if (!streams.has(streamId)) return - // Snapshot first (current screen) — then live output begins on attach. + if (streams.get(streamId) !== stream) return + socket.respond(req.id, true, { streamId, fresh: prep.fresh ?? !existed, + ...(pty.scrollAttached ? { scrollV1: true } : {}) }) sendSnapshot(streamId, stream, snapshot) try { const sessionId = await prep.attach(sinks) - if (!streams.has(streamId)) { - // Closed while the spawn was in flight (a remote spawn awaits the master's pacing gate): - // release the client we just made; the session itself keeps running. + if (streams.get(streamId) !== stream) { + // A closed stream cannot adopt a client whose remote spawn was still in flight. pty.kill(null, sessionId) return } stream.sessionId = sessionId + const size = stream.pendingSize + stream.pendingSize = undefined + if (size) pty.resize(null, sessionId, size.cols, size.rows) + const input = stream.pendingInput + stream.pendingInput = [] + stream.pendingInputBytes = 0 + for (const item of input) { + if (streams.get(streamId) !== stream) break + pty.write(item.clientId, sessionId, item.data) + } } catch { - // Attach failed (e.g. tmux unavailable, master gone) — surface as an exit so the client - // tears down. - socket.sendFrame( - OP.Error, - streamId, - stream.seq++, - textEncoder.encode(JSON.stringify({ exitCode: 1 })) - ) + if (stream.sessionId) pty.kill(null, stream.sessionId) + socket.sendFrame(OP.Error, streamId, stream.seq++, + textEncoder.encode(JSON.stringify({ exitCode: 1 }))) dropStream(streamId) } - })() + })().catch(() => { + if (streams.get(streamId) !== stream) return + socket.respond(req.id, false, { message: 'Could not prepare this session.' }) + dropStream(streamId) + }) } // Serve a `fs.*` RPC by calling the shared fs-ops on the host's real filesystem and responding @@ -524,28 +726,118 @@ export function createHostHandlers( } } - /** - * Scroll a remote client's view of the session's tmux history. - * - * Scrolling belongs to tmux (its mouse is on and the pane lives on the alternate screen, so the - * client's emulator has no scrollback of its own). This stream's pty IS a tmux client, so the - * wheel is simply written into it as an SGR mouse event and tmux does the rest — no tmux command - * channel, no copy-mode bookkeeping on our side. The phone drives this because it cannot deliver - * the wheel itself: its own emulator would swallow the gesture. - * - * `lines` is untrusted (it arrives from a remote client) — clamp it, and address the wheel to - * cell 1,1 so a hostile value cannot be interpolated anywhere interesting. - */ + /** One FIFO per captured viewer. A mixed scroll may write input, so it is never replayed. */ + function queueScroll(req: RpcRequest, stream: Stream, work: () => Promise): void { + if (stream.pendingScroll >= 32) { + socket.respond(req.id, true, { status: 'refused', message: 'Terminal scrolling is busy. Try again.' }) + return + } + stream.pendingScroll++ + const turn = stream.scrollTail.then(work) + stream.scrollTail = turn.catch(() => {}).then(() => { stream.pendingScroll-- }) + void turn.catch(() => { + stream.history.clear() + socket.respond(req.id, true, { + status: 'uncertain', message: 'The scrolling result is unknown. It was not retried.' + }) + }) + } + + // Compatibility RPC: only a positively resolved tmux viewer may receive the old SGR wheels. + // Native retained history needs the negotiated page response; never inject wheels when off. function handleScroll(req: RpcRequest): void { const p = asRecord(req.params) - const stream = streams.get(num(p.streamId, -1)) - if (stream) { - const up = str(p.dir) !== 'down' - const notches = Math.min(20, Math.max(1, Math.floor(num(p.lines, 1)))) - const seq = `\x1b[<${up ? 64 : 65};1;1M` - for (let i = 0; i < notches; i++) pty.write(getClientId(), stream.sessionId, seq) + const streamId = num(p.streamId, -1), stream = streams.get(streamId) + if (!stream) { socket.respond(req.id, true, {}); return } + if (!stream.sessionId || !pty.scrollAttached) { + socket.respond(req.id, false, { message: 'Safe scrolling is not served on this attached terminal.' }); return } - socket.respond(req.id, true, {}) + const sessionId = stream.sessionId + const current = (): boolean => streams.get(streamId) === stream && stream.sessionId === sessionId + const up = str(p.dir) !== 'down', notches = Math.min(20, Math.max(1, Math.floor(num(p.lines, 1)))) + queueScroll(req, stream, async () => { + if (!current()) { socket.respond(req.id, false, { message: 'This terminal is no longer attached.' }); return } + const result = await pty.scrollAttached!(getClientId(), sessionId, up, notches, false, current) + if (result.status === 'input') { stream.history.clear(); socket.respond(req.id, true, {}); return } + socket.respond(req.id, false, { message: result.status === 'history' + ? 'Update the phone app to browse this terminal’s retained history.' : result.message }) + }) + } + + function handleScrollV1(req: RpcRequest): void { + const p = asRecord(req.params) + const streamId = num(p.streamId, -1), stream = streams.get(streamId) + const refuse = (message: string): void => socket.respond(req.id, true, { status: 'refused', message }) + if (!Number.isInteger(streamId) || !stream?.sessionId) { refuse('This terminal is no longer attached.'); return } + if (!pty.scrollAttached) { refuse('Update nodeterm on the computer to browse terminal history.'); return } + if ((p.dir !== 'up' && p.dir !== 'down') || !Number.isInteger(p.lines) || + (p.lines as number) < 1 || (p.lines as number) > 20 || + (p.viewId !== undefined && (typeof p.viewId !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(p.viewId)))) { + refuse('Invalid terminal history movement.'); return + } + const up = p.dir === 'up', notches = p.lines as number, viewId = p.viewId as string | undefined + const sessionId = stream.sessionId + const current = (): boolean => streams.get(streamId) === stream && stream.sessionId === sessionId + queueScroll(req, stream, async () => { + if (!current()) { refuse('This terminal is no longer attached.'); return } + let capture: boolean + try { capture = stream.history.needsCapture(viewId) } + catch (error) { refuse(error instanceof Error ? error.message : 'This history view expired.'); return } + // Once history is open it is an inert, immutable view. Application output enabling mouse + // reporting underneath it must not redirect these gestures into foreground input. + if (!capture) { + try { socket.respond(req.id, true, stream.history.page(up, notches)) } + catch (error) { + stream.history.clear() + refuse(error instanceof Error ? error.message : 'Terminal history could not be read.') + } + return + } + const result = await pty.scrollAttached!(getClientId(), sessionId, up, notches, capture, current) + if (result.status === 'input') { stream.history.clear(); socket.respond(req.id, true, result); return } + if (result.status !== 'history') { stream.history.clear(); socket.respond(req.id, true, result); return } + if (!current()) { refuse('The terminal detached while its history was read.'); return } + try { + if (capture) { + if (!result.capture) throw new Error('The computer did not return terminal history.') + stream.history.install(result.capture) + } + socket.respond(req.id, true, stream.history.page(up, notches)) + } catch (error) { + stream.history.clear() + refuse(error instanceof Error ? error.message : 'Terminal history could not be read.') + } + }) + } + + function handleHistorySearch(req: RpcRequest): void { + const p = asRecord(req.params) + const streamId = num(p.streamId, -1) + const stream = streams.get(streamId) + if (!stream?.sessionId) { socket.respond(req.id, false, { message: 'This terminal is no longer attached.' }); return } + if (!pty.historySearch) { socket.respond(req.id, false, { message: 'History search is not served on this host. Update nodeterm on the computer.' }); return } + if (!validHistoryQuery(p.query)) { socket.respond(req.id, false, { message: 'Enter a single-line search of 1–256 characters.' }); return } + void pty.historySearch(stream.sessionId, p.query).then((result) => { + if (streams.get(streamId) !== stream) throw new Error('The terminal detached while its history was searched.') + socket.respond(req.id, true, result) + }).catch((error: unknown) => socket.respond(req.id, false, { message: error instanceof Error ? error.message : 'History search failed.' })) + } + + function handleSubmitComposed(req: RpcRequest): void { + const p = asRecord(req.params) + const streamId = num(p.streamId, -1) + const stream = streams.get(streamId) + const input = parseComposedInput(p.input) + if (!input) { socket.respond(req.id, false, { message: 'Invalid composed terminal input.' }); return } + if (!stream?.sessionId) { socket.respond(req.id, false, { message: 'This terminal is no longer attached.' }); return } + if (!pty.submitComposed) { socket.respond(req.id, false, { message: COMPOSED_INPUT_UNSUPPORTED }); return } + const current = (): boolean => streams.get(streamId) === stream + void pty.submitComposed(stream.sessionId, input, current).then((result) => { + // Once dispatched, a detached viewer can still have received input. Never turn that into a + // proven refusal or retry; the client also binds completion to its own presentation ticket. + socket.respond(req.id, true, result) + }).catch(() => socket.respond(req.id, true, { status: 'uncertain', message: COMPOSED_INPUT_UNCERTAIN })) } // Serve a typed `git.*` verb against the injected GitService slice, jailed to the shared roots @@ -722,6 +1014,71 @@ export function createHostHandlers( .catch(() => socket.respond(req.id, true, { moved: false })) } + /** + * `approvals.answer {nodeId, pendingId, decision}` → `{answered}` and `inbox.ack {nodeId}` → `{}` + * (see `HostInboxOps`). Every field is client-sent and validated here before it reaches a path: the + * pendingId with the SAME rule the answer writer applies (`isValidPendingId` — it becomes a file + * name), the node id with the rule `node.*` applies. `answered:false` is an ANSWER, not a protocol + * error, and it carries WHY in `reason`: `gone` (the hook's hold already ended — its request file is + * gone, so the interactive prompt is on screen now or someone else answered) or `failed` (the host + * could not write). `reason` is additive: a phone that predates it reads only `answered`. + */ + function handleInbox(req: RpcRequest): void { + if (!inbox) { + socket.respond(req.id, false, { message: `${req.method} is not served on this host.` }) + return + } + const p = asRecord(req.params) + const nodeId = str(p.nodeId) + // eslint-disable-next-line no-control-regex -- refusing control chars is the point + if (!nodeId || nodeId.length > REF_MAX_LEN || /[\x00-\x1f\x7f-\x9f]/.test(nodeId)) { + socket.respond(req.id, false, { message: 'Invalid node id.' }) + return + } + if (req.method === 'inbox.ack') { + try { + inbox.ackRead(nodeId) + } catch { + /* best-effort, exactly like the file-drop sweep it mirrors */ + } + socket.respond(req.id, true, {}) + return + } + const pendingId = str(p.pendingId) ?? '' + if (req.method === 'questions.answer') { + if (!isValidPendingId(pendingId) || !Array.isArray(p.selections) || p.selections.length < 1 || p.selections.length > 4 || + p.selections.some(row => !Array.isArray(row) || row.length < 1 || row.length > 4 || row.some(n => !Number.isInteger(n) || n < 0 || n > 3))) { + socket.respond(req.id, false, { message: 'questions.answer requires a live ticket and bounded option indexes.' }) + return + } + if (!inbox.answerQuestion) { + socket.respond(req.id, false, { message: 'questions.answer is not served on this host.' }) + return + } + void inbox.answerQuestion(nodeId, pendingId, p.selections as number[][]) + .then(result => socket.respond(req.id, true, result === 'sent' ? { answered: true } : { answered: false, reason: result })) + .catch(() => socket.respond(req.id, true, { answered: false, reason: 'failed' })) + return + } + const decision = p.decision + const suggestionIndex = typeof p.suggestionIndex === 'number' ? p.suggestionIndex : undefined + if (!isValidPendingId(pendingId) || (decision !== 'allow' && decision !== 'deny' && decision !== 'allow-always') || + (decision === 'allow-always' && (!Number.isInteger(suggestionIndex) || suggestionIndex! < 0 || suggestionIndex! > 31))) { + socket.respond(req.id, false, { + message: 'approvals.answer requires a pendingId and a decision of allow or deny.' + }) + return + } + const operation = decision === 'allow-always' + ? inbox.answerPermission(nodeId, pendingId, decision, suggestionIndex) + : inbox.answerPermission(nodeId, pendingId, decision) + void operation + .then((result) => + socket.respond(req.id, true, result === 'sent' ? { answered: true } : { answered: false, reason: result }) + ) + .catch(() => socket.respond(req.id, true, { answered: false, reason: 'failed' })) + } + function handleKill(req: RpcRequest): void { const streamId = num(asRecord(req.params).streamId, -1) const stream = streams.get(streamId) @@ -804,6 +1161,69 @@ export function createHostHandlers( * "delivered to a live desktop window", never "the action happened" — that contract is in the * verb docs the iOS client mirrors. */ + /** + * `node.sendKeys {nodeId, keys}` → `{sent}`. The phone's Inbox answers used to attach a throwaway + * 80x24 tmux client, write, and kill it at once: an Input that beat the async attach was dropped + * (no session yet), and one that did not could die with the client before tmux read it — a lone + * ESC (Deny) nearly always, inside tmux's escape-time (audit A12). This types through the node's + * existing session instead. `sent:false` is an ANSWER (not delivered: open the session). + * + * A node of one of the desktop's SSH projects is typed into on ITS host, over the project's + * ControlMaster (`pty.backgroundWriteOver`); it used to answer `sent:false` outright, which sent + * the phone to open the session for what is meant to be a one-tap answer. The master being down + * (or still running its connect setup) is still `sent:false`, and so is a node the host does not + * have — the remote target is exact, so a miss never lands in another node's session. + */ + function handleSendKeys(req: RpcRequest): void { + if (!nodeActions?.sendKeys) { + socket.respond(req.id, false, { message: `${req.method} is not served on this host.` }) + return + } + const p = asRecord(req.params) + const nodeId = str(p.nodeId) + // eslint-disable-next-line no-control-regex -- refusing control chars is the point + if (!nodeId || nodeId.length > REF_MAX_LEN || /[\x00-\x1f\x7f-\x9f]/.test(nodeId)) { + socket.respond(req.id, false, { message: 'Invalid node id.' }) + return + } + const keys = str(p.keys) ?? '' + if (!SEND_KEYS_RE.test(keys)) { + socket.respond(req.id, false, { message: 'node.sendKeys takes a short answer: up to 16 printable characters, Esc or Enter.' }) + return + } + // A writer that throws (synchronously or not) delivered nothing: `sent:false`, never a dropped reply. + const answer = (deliver: () => Promise): void => { + void Promise.resolve() + .then(deliver) + .then((sent) => socket.respond(req.id, true, { sent: sent === true })) + .catch(() => socket.respond(req.id, true, { sent: false })) + } + // A node of an SSH project: its tmux is on THAT host. Type over the project's ControlMaster — + // the same resolver `pty.attach` uses for such a node (audit A09) — or answer `sent:false`, + // which the phone reads as "open the session". Never the local socket: an `nt-` there is at + // best nothing and at worst a phantom left by an old attach. A resolver that throws has not + // said the node is local, so that is `sent:false` too, not a guess. + let owner: ReturnType + try { + owner = remoteNodes?.resolve(nodeId) ?? null + } catch { + socket.respond(req.id, true, { sent: false }) + return + } + if (owner) { + if (!owner.sshRemote || !pty.backgroundWriteOver) { + socket.respond(req.id, true, { sent: false }) + return + } + const { sshRemote } = owner + const over = pty.backgroundWriteOver.bind(pty) + answer(() => over(nodeId, keys, sshRemote)) + return + } + const local = nodeActions.sendKeys.bind(nodeActions) + answer(() => local(nodeId, keys)) + } + function handleNodeAction(req: RpcRequest): void { if (!nodeActions) { socket.respond(req.id, false, { message: `${req.method} is not served on this host.` }) @@ -958,6 +1378,15 @@ export function createHostHandlers( case 'pty.scroll': handleScroll(req) break + case 'pty.scrollV1': + handleScrollV1(req) + break + case 'pty.historySearch': + handleHistorySearch(req) + break + case 'pty.submitComposed': + handleSubmitComposed(req) + break case 'fs.list': case 'fs.read': case 'fs.readBinary': @@ -993,13 +1422,27 @@ export function createHostHandlers( case 'node.rename': handleNodeAction(req) break + case 'node.sendKeys': + handleSendKeys(req) + break + case 'approvals.answer': + case 'questions.answer': + case 'inbox.ack': + handleInbox(req) + break case 'projects.list': // Read-only enumeration of the host's projects/sessions/agent-status (no client params — // nothing to jail). Gated by the same pre-handler approval check in connectHostSession, so // an unapproved device never reaches here. Always respond ok; degrade to an empty blob. - void listProjects() - .then((output) => socket.respond(req.id, true, { output })) - .catch(() => socket.respond(req.id, true, { output: '' })) + // `lan` (additive, A74-refresh) rides beside the blob, never inside it: the blob is the + // shape a phone on direct SSH reads off the host itself, and the address and keys must + // reach the phone only over this authenticated channel. A failed read leaves it out. + void Promise.all([ + listProjects().catch(() => ''), + Promise.resolve() + .then(() => lanReport?.() ?? null) + .catch(() => null) + ]).then(([output, lan]) => socket.respond(req.id, true, { output, ...(lan ? { lan } : {}) })) break default: socket.respond(req.id, false, { message: `Unknown method: ${req.method}` }) @@ -1009,7 +1452,22 @@ export function createHostHandlers( const stream = streams.get(frame.streamId) if (!stream) return if (frame.op === OP.Input) { - pty.write(getClientId(), stream.sessionId, textDecoder.decode(frame.payload)) + const data = textDecoder.decode(frame.payload) + if (!stream.sessionId) { + // The attach reply must precede snapshot frames for the phone to register the stream. + // Its first input can arrive while a remote master is still pacing the async commit. + // Keep only this stream's bounded input; close/refusal/overflow can never replay it. + if (stream.pendingInput.length >= 32 || stream.pendingInputBytes + frame.payload.length > 64 * 1024) { + socket.sendFrame(OP.Error, frame.streamId, stream.seq++, + textEncoder.encode(JSON.stringify({ exitCode: 1 }))) + dropStream(frame.streamId) + return + } + stream.pendingInput.push({ clientId: getClientId(), data }) + stream.pendingInputBytes += frame.payload.length + } else { + pty.write(getClientId(), stream.sessionId, data) + } return } if (frame.op === OP.Resize) { @@ -1022,7 +1480,9 @@ export function createHostHandlers( ) // null clientId: this pty is relay-served (its sink is the only "subscriber"), so the // mirrored client's size is recorded against the sink rather than a UI client id. - pty.resize(null, stream.sessionId, view.getUint16(0, true), view.getUint16(2, true)) + const size = { cols: view.getUint16(0, true), rows: view.getUint16(2, true) } + if (!stream.sessionId) stream.pendingSize = size + else pty.resize(null, stream.sessionId, size.cols, size.rows) } } }, @@ -1036,6 +1496,10 @@ export function createHostHandlers( } streams.clear() for (const stream of closing) { + stream.history.clear() + stream.pendingInput = [] + stream.pendingInputBytes = 0 + stream.pendingSize = undefined try { remoteViewer?.detached(stream.persistKey) } catch { @@ -1176,6 +1640,17 @@ export function relayAllowed(): boolean { // --- shared host session (interactive host + standing phone host) ------------ +/** + * The longest a freshly bridged peer's requests are held while an async `onPeerReady` decides + * without the human (connectHostSession). The decision is a few local disk reads and at most one + * pin write (whose rename retries add up to about 0.3 s on Windows), so this is generous; a + * decision stuck past it (a hung disk) degrades to the answer every request got before holding + * existed, "Awaiting host approval.". Far under the phone's own request timeout (30 s on Android). + */ +export const PEER_DECISION_HOLD_MS = 5_000 +/** Requests beyond this many, while holding, are answered at once (a peer flooding the hold). */ +export const PEER_DECISION_HOLD_MAX = 32 + /** * A live host<->client relay session: the bridged relay socket plus its RPC/frame handlers and * canvas mirror, gated by an approval flag. Both the interactive remote host and the standing @@ -1196,14 +1671,18 @@ export interface HostSession { } export interface HostSessionOptions { + /** Optional on the standing phone host only; approved retained-SSH association, never approval. */ + legacyRelayPairings?: LegacyRelayPairings /** Relay wss URL. */ url: string /** Single-use pairing token gating entry at the relay. */ token: string /** The long-lived host NaCl keypair. */ ourKeys: KeyPair - /** The real pty-manager whose tmux sessions the peer attaches to. */ - pty: PtyManager + /** The pty-manager whose tmux sessions the peer attaches to: the real PtyManager in both hosts. + * Typed as the slice the session uses (it only hands it to createHostHandlers), so the Android + * interop fixture's fake is checked against exactly that, with no cast (audit A67). */ + pty: HostPtyManager /** The host renderer's latest active-project canvas snapshot (source of the mirror). */ getLatestCanvas(): CanvasState | null /** Subscribe to canvas updates so the mirror re-broadcasts. Returns unsubscribe. */ @@ -1237,6 +1716,16 @@ export interface HostSessionOptions { /** Kanban board writes for the phone's Board sheet (`projects.ensureBoard` / * `projects.setCardColumn`). Optional: absent ⇒ the verbs answer an honest "not served". */ kanban?: HostKanbanOps + /** Inbox actions for a relay phone (`approvals.answer` / `inbox.ack`). Optional: absent ⇒ the + * verbs answer an honest "not served". */ + inbox?: HostInboxOps + /** SSH-project nodes: attach over their master, never locally. Optional (see HostRemoteNodes). */ + remoteNodes?: HostRemoteNodes + /** Where a phone-started session is created (see HostNewSessions). Optional. */ + newSessions?: HostNewSessions + /** This computer's current LAN address and SSH host keys for the `projects.list` answer's `lan` + * field (audit A74-refresh). Optional: absent ⇒ no `lan` field. */ + lanReport?: () => Promise /** The phone's Chat screen verbs (`chat.*` / `agent.answer`). Optional: absent ⇒ "not served". */ chat?: HostChatOps /** Extra fs/git jail roots beyond the shared canvas's node cwds — production passes the @@ -1247,8 +1736,14 @@ export interface HostSessionOptions { * A peer completed the E2EE handshake and awaits an approval decision. The caller inspects the * session (sas / peerPublicKeyB64) and either approves immediately (pin-once) or prompts the * host human, later calling `approve()`. + * + * May return a promise for a decision it makes WITHOUT the human (the standing host reads its pin + * store and may pin a paired phone's key, all on disk): until it settles, or until `approve()`, + * the peer's requests are HELD rather than refused (see PEER_DECISION_HOLD_MS). It settles once + * the session is approved or handed to the human; a request held until then is answered as if it + * had arrived then. A plain `void` (the interactive host) holds nothing. */ - onPeerReady(session: HostSession): void + onPeerReady(session: HostSession): void | Promise /** The relay socket dropped (client/relay gone). */ onClose(): void } @@ -1262,6 +1757,38 @@ export function connectHostSession(opts: HostSessionOptions): HostSession { // Approval gate: a freshly-bridged peer serves NO pty/fs RPCs or input frames until approved, // so a leaked/guessed pairing cannot grant silent access. Reset on every (re)connect. let approved = false + let closed = false + const pairingProof = opts.legacyRelayPairings ? createRelayPairingProof({ + hostKey: publicKeyToB64(opts.ourKeys.publicKey), peerKey: () => socket.peerPublicKeyB64(), + current: () => approved && !closed, pairings: opts.legacyRelayPairings + }) : null + // Requests held while an async `onPeerReady` is still making its silent decision (review of + // A07-late). The host fires onReady the moment it confirms the handshake, so the phone's first + // request can arrive one relay round trip later, while the standing host is still reading its + // pin store (and, for a paired phone it has not pinned yet, reading agent.json and writing the + // pin). Answering that request "Awaiting host approval." told the phone a human had to approve + // it: a background check (Android RelayConnector with requireApproved) gives up on the first such + // answer and forgets that this computer approved it. Held instead, the request is answered once + // the decision is in: served when it approved, "Awaiting host approval." when it went to the + // human. Never held longer than PEER_DECISION_HOLD_MS or beyond PEER_DECISION_HOLD_MAX requests: + // past either, the answer is the one this gave before. + let deciding = false + let held: RpcRequest[] = [] + let holdTimer: ReturnType | null = null + function stopHolding(): RpcRequest[] { + if (holdTimer) { + clearTimeout(holdTimer) + holdTimer = null + } + deciding = false + const queue = held + held = [] + return queue + } + /** The decision is in (approved, handed to the human, or out of time): answer what was held, in order. */ + function releaseHeld(): void { + for (const req of stopHolding()) handleRpc(req) + } let handlers: HostHandlers | null = null let canvasSync: HostCanvasSync | null = null let unsubCanvas: (() => void) | null = null @@ -1287,9 +1814,12 @@ export function connectHostSession(opts: HostSessionOptions): HostSession { const session: HostSession = { approve() { + if (closed) return approved = true // Flush the current canvas now that the device is trusted. pushCurrentCanvas() + // Serve what the peer asked while the decision was being made, before anything it asks next. + releaseHeld() }, isApproved() { return approved @@ -1301,11 +1831,14 @@ export function connectHostSession(opts: HostSessionOptions): HostSession { return socket.peerPublicKeyB64() }, close() { + closed = true + pairingProof?.close() if (broadcastTimer) { clearTimeout(broadcastTimer) broadcastTimer = null } approved = false + stopHolding() // nothing held is answered on a session being torn down unsubCanvas?.() unsubCanvas = null handlers?.closeAll() @@ -1321,35 +1854,72 @@ export function connectHostSession(opts: HostSessionOptions): HostSession { role: 'host', ourKeys: opts.ourKeys, onReady: () => { + if (closed) return // Bridge established. Require approval before serving ANYTHING — including canvas state // (which carries workspace metadata). Nothing is pushed until approve() flushes it. approved = false - opts.onPeerReady(session) + // Hold from before the call, so an approve() made synchronously inside it releases nothing + // that is later held again. + deciding = true + let decision: unknown + try { + decision = opts.onPeerReady(session) + } catch (err) { + releaseHeld() + throw err + } + if (!deciding) return // approve() already ran, inside the call + if (decision instanceof Promise) { + holdTimer = setTimeout(releaseHeld, PEER_DECISION_HOLD_MS) + holdTimer.unref?.() + decision.then(releaseHeld, releaseHeld) // a failed decision answers as one that went to the human + } else { + releaseHeld() // a synchronous decision: nothing to wait for + } }, onRpc: (req) => { - // Until approved, refuse every request — pty/fs RPCs, client mutations, AND canvas - // snapshots (canvas:request). An unapproved device gets nothing but the approval prompt. - if (!approved) { - if (req.id) socket.respond(req.id, false, { message: 'Awaiting host approval.' }) + if (deciding && held.length < PEER_DECISION_HOLD_MAX) { + held.push(req) return } - // A client asking for a fresh canvas snapshot → re-push the current one (read-only). - if (req.method === CANVAS_REQUEST_METHOD) { - canvasSync?.broadcastCurrent() - return - } - if (canvasSync?.handleRpc(req)) return - handlers?.onRpc(req) + handleRpc(req) }, onFrame: (frame) => { if (approved) handlers?.onFrame(frame) }, onClose: () => { + closed = true + pairingProof?.close() approved = false + stopHolding() handlers?.closeAll() opts.onClose() } }) + function handleRpc(req: RpcRequest): void { + // Until approved, refuse every request — pty/fs RPCs, client mutations, AND canvas + // snapshots (canvas:request). An unapproved device gets nothing but the approval prompt. + if (!approved) { + if (req.id) socket.respond(req.id, false, { message: 'Awaiting host approval.' }) + return + } + if (req.method === RELAY_PAIRING_CHALLENGE_METHOD || req.method === RELAY_PAIRING_PROOF_METHOD) { + if (!req.id) return // notifications cannot allocate/consume a proof + if (!pairingProof) { socket.respond(req.id, false, { message: 'Relay pairing proof is not served on this host.' }); return } + const result = req.method === RELAY_PAIRING_CHALLENGE_METHOD ? pairingProof.challenge(req.params) : pairingProof.proof(req.params) + void result.then(body => { if (!closed) socket.respond(req.id, true, body) }, () => { + if (!closed) socket.respond(req.id, false, { message: 'The pairing identity could not be verified. Pair again to repair it.' }) + }) + return + } + // A client asking for a fresh canvas snapshot → re-push the current one (read-only). + if (req.method === CANVAS_REQUEST_METHOD) { + canvasSync?.broadcastCurrent() + return + } + if (canvasSync?.handleRpc(req)) return + handlers?.onRpc(req) + } // Confine the client's fs.* access to the cwds of the host's currently-shared canvas nodes. handlers = createHostHandlers( opts.pty, @@ -1364,6 +1934,10 @@ export function connectHostSession(opts: HostSessionOptions): HostSession { opts.remoteViewer, opts.nodeActions, opts.kanban, + opts.inbox, + opts.remoteNodes, + opts.newSessions, + opts.lanReport, opts.chat ) canvasSync = createHostCanvasSync(socket, opts.applyMutation) @@ -1395,10 +1969,19 @@ export interface HostBridgeDeps { nodeActions?: HostNodeActions /** Kanban board writes for the phone's Board sheet — see main/index.ts's WorkspaceStore wiring. */ kanban?: HostKanbanOps + /** Inbox actions for a relay phone — the same answer writer / read-ack the desktop itself uses. */ + inbox?: HostInboxOps + /** SSH-project nodes attach over their project's ControlMaster, or are refused (audit A09). */ + remoteNodes?: HostRemoteNodes + /** A phone-started session is created in its project's folder, under its account (audit A33). */ + newSessions?: HostNewSessions /** The phone's Chat screen verbs — see main/remote/host-chat.ts and main/index.ts's wiring. */ chat?: HostChatOps /** Workspace-level jail roots (local project cwds) merged with the canvas node cwds. */ workspaceRoots?: () => string[] + /** This computer's current LAN address and SSH host keys, beside every `projects.list` answer, so + * a relay phone can refresh the LAN leg it dials (audit A74-refresh, host-lan-report.ts). */ + lanReport?: () => Promise } export function initRemoteHost( @@ -1478,8 +2061,12 @@ export function initRemoteHost( remoteViewer: bridge.remoteViewer, nodeActions: bridge.nodeActions, kanban: bridge.kanban, + inbox: bridge.inbox, + remoteNodes: bridge.remoteNodes, + newSessions: bridge.newSessions, chat: bridge.chat, extraRoots: bridge.workspaceRoots, + lanReport: bridge.lanReport, // Typing attribution: this session's input frames are this phone's keystrokes. getClientId: () => phone.id(), // Interactive host: surface the SAS + a fresh pending id so the human can verify + approve. diff --git a/src/main/remote/peer-revoke.ts b/src/main/remote/peer-revoke.ts index 9408f974f3..3c0dfaabaf 100644 --- a/src/main/remote/peer-revoke.ts +++ b/src/main/remote/peer-revoke.ts @@ -92,11 +92,10 @@ export function revokePeerKey(peerKeyB64: string, roles: readonly PinRole[]): Pr * phone relay session (standing and interactive hosts, approved or awaiting SAS). Desktop Team * Access sessions are not touched. * - * All phones, on purpose: the phone's relay box key is never sent at pairing, so nothing on this - * machine maps an agent.json device to the key it presents over the relay — and a phone approved on - * the interactive host is never pinned at all. Unpinning or cutting a guess could leave the removed - * phone connected; doing all of them costs every OTHER phone a reconnect and one SAS comparison. - * Fail-closed wins. + * Legacy fallback only: older pairing records have no relay box key, so the removed phone cannot + * be identified precisely. A phone approved on the interactive host may not be pinned at all. + * Cutting a guessed key could leave the removed phone connected; this fallback costs every OTHER + * phone a reconnect and one SAS comparison. Pairings that recorded the key use exact-key revoke. */ export async function revokeAllPhones(): Promise { let persisted = true diff --git a/src/main/remote/peer-revoker.ts b/src/main/remote/peer-revoker.ts new file mode 100644 index 0000000000..6d1d570b7b --- /dev/null +++ b/src/main/remote/peer-revoker.ts @@ -0,0 +1,35 @@ +// The one revoker for a relay peer's box key, used by BOTH ways this desktop forgets someone: +// `remote:revoke-peer` (a peer desktop) and Settings → Phone → Revoke (a paired phone, through +// pairing-service's `revokeRelayKey`). +// +// revocation.ts holds the rule: persist the unpin FIRST, so a reconnect racing the teardown is +// already refused by the pin check, and THEN cut every live session of that key, even when the +// write failed. This module only names the sessions there are to cut. A phone's relay session lives +// in the standing host's pool and a peer desktop's in relay-host's live set, and a key is cut from +// both: unpinning alone left a forgotten phone that was connected at that moment serving terminals, +// files and the canvas until its socket dropped on its own (audit A07-revoke). +import { createRevoker, type RevokeResult } from './revocation' +import { loadApprovedDevices, saveApprovedDevices, updateApprovedDevices } from './approved-devices' +import { killRelayHostsByPeerKey } from './relay-host' +import { killStandingHostSessionsByPeerKey } from './standing-host' + +export function createPeerRevoker(): { revoke(peerKeyB64: string): Promise } { + return createRevoker({ + load: loadApprovedDevices, + save: saveApprovedDevices, + update: updateApprovedDevices, + onRevoke: (peerKeyB64) => { + // Both, even when one throws: a failure in one host must not leave the other's session open. + // The first error is rethrown so the revoke reports `killed: false`. + const errors: unknown[] = [] + for (const kill of [killRelayHostsByPeerKey, killStandingHostSessionsByPeerKey]) { + try { + kill(peerKeyB64) + } catch (err) { + errors.push(err) + } + } + if (errors.length) throw errors[0] + } + }) +} diff --git a/src/main/remote/relay-advertise.ts b/src/main/remote/relay-advertise.ts index 19756dc907..ba7e37c238 100644 --- a/src/main/remote/relay-advertise.ts +++ b/src/main/remote/relay-advertise.ts @@ -8,7 +8,10 @@ // bootstrap it already uses for agent.json, then mints its own device token against the API // (the free-tier TOFU mint). The file carries ONLY public material — the same block every // pairing QR embeds, plus this desktop's device id for that mint; relay ACCESS remains gated -// host-side by the pin-once SAS approval, exactly as for a pairing-minted identity. +// host-side by the pin, exactly as for a pairing-minted identity. A phone whose pairing recorded its +// relay key from the sealed /pair body is pinned on its first handshake (standing-host.ts, +// `pinPairedPhone`, audit A07-late); any other key still needs the SAS approval. Reading this file +// grants nothing: the token it lets a phone mint only reaches the handshake. import { promises as fs } from 'fs' import os from 'os' diff --git a/src/main/remote/relay-pairing-proof.test.ts b/src/main/remote/relay-pairing-proof.test.ts new file mode 100644 index 0000000000..72c2f9d954 --- /dev/null +++ b/src/main/remote/relay-pairing-proof.test.ts @@ -0,0 +1,102 @@ +import { generateKeyPairSync, sign } from 'crypto' +import { describe, expect, it, vi } from 'vitest' +import { createRelayPairingProof, type LegacyRelayPairings } from './relay-pairing-proof' +import { relayPairingChallengeText, type RelayPairingChallenge, type RelayPairingChallengeResult } from '../../shared/relay-pairing-proof' +const id = '11111111-1111-4111-8111-111111111111' +function setup() { + const keys = generateKeyPairSync('ed25519') + const publicRaw = keys.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('base64') + let wall = 1_800_000_000_000, mono = 0, active = true, peer = Buffer.alloc(32, 2).toString('base64') + const pairings: LegacyRelayPairings = { + inspect: vi.fn(async () => ({ status: 'eligible', target: { pairingId: id, sshPublicKeyB64: publicRaw, entryVersion: 'private-version' } })), + associate: vi.fn(async (_target, _peer, current) => ({ status: current() ? 'associated' : 'expired' })) + } + const proof = createRelayPairingProof({ hostKey: Buffer.alloc(32, 1).toString('base64'), peerKey: () => peer, + current: () => active, pairings, now: () => wall, monotonicNow: () => mono }) + const signature = (c: RelayPairingChallenge) => sign(null, Buffer.from(relayPairingChallengeText(c)), keys.privateKey).toString('base64') + const request = (c: RelayPairingChallenge) => ({ challengeId: c.challengeId, signatureB64: signature(c) }) + const challenge = async () => { + const result = await proof.challenge({ pairingId: id }); expect(result.status).toBe('challenge') + return (result as Extract).challenge + } + return { proof, pairings, challenge, request, signature, setActive: () => { active = false }, + changePeer: () => { peer = Buffer.alloc(32, 5).toString('base64') }, + age: (ms: number, clock: 'both' | 'wall' | 'mono' = 'both') => { if (clock !== 'mono') wall += ms; if (clock !== 'wall') mono += ms } } +} +describe('single approved-session relay pairing proof', () => { + it('verifies the actual SSH signature and private captured target, without changing pins', async () => { + const s = setup(), c = await s.challenge() + expect(await s.proof.proof(s.request(c))).toEqual({ status: 'associated' }) + expect(s.pairings.associate).toHaveBeenCalledWith(expect.objectContaining({ pairingId: id, entryVersion: 'private-version' }), c.peerPublicKeyB64, expect.any(Function)) + expect(await s.proof.proof(s.request(c))).toEqual({ status: 'unprovable' }) + expect(s.pairings.associate).toHaveBeenCalledTimes(1) + }) + it.each(['challengeId', 'pairingId', 'hostPublicKeyB64', 'peerPublicKeyB64', 'sshPublicKeyB64', 'nonceB64', 'expiresAtMs'] as const)( + 'rejects a signature over altered %s', async field => { + const s = setup(), c = await s.challenge() + const value = field === 'expiresAtMs' ? c.expiresAtMs + 1 : field.endsWith('Id') ? '22222222-2222-4222-8222-222222222222' : Buffer.alloc(32, 8).toString('base64') + expect(await s.proof.proof({ challengeId: c.challengeId, signatureB64: s.signature({ ...c, [field]: value }) })).toEqual({ status: 'unprovable' }) + expect(s.pairings.associate).not.toHaveBeenCalled() + }) + it('consumes malformed proof and refuses wrong SSH signers', async () => { + const s = setup(), c = await s.challenge() + expect(await s.proof.proof({ challengeId: c.challengeId, signatureB64: 'bad' })).toEqual({ status: 'unprovable' }) + expect(await s.proof.proof(s.request(c))).toEqual({ status: 'unprovable' }) + const next = await s.challenge() + const key = generateKeyPairSync('ed25519').privateKey + expect(await s.proof.proof({ challengeId: next.challengeId, signatureB64: sign(null, Buffer.from(relayPairingChallengeText(next)), key).toString('base64') })).toEqual({ status: 'unprovable' }) + expect(s.pairings.associate).not.toHaveBeenCalled() + }) + it.each(['both', 'wall', 'mono'] as const)('expires at the exact bound using %s time', async clock => { + const s = setup(), c = await s.challenge(); s.age(60_000, clock) + expect(await s.proof.proof(s.request(c))).toEqual({ status: 'expired' }); expect(s.pairings.associate).not.toHaveBeenCalled() + }) + it('replacement creates fresh randomness and retires the old challenge', async () => { + const s = setup(), old = await s.challenge(), next = await s.challenge() + expect(next.challengeId).not.toBe(old.challengeId); expect(next.nonceB64).not.toBe(old.nonceB64) + expect(await s.proof.proof(s.request(old))).toEqual({ status: 'unprovable' }) + expect(await s.proof.proof(s.request(next))).toEqual({ status: 'unprovable' }) // failed old proof consumed newest + }) + it('close is permanent and rejects a proof from another exact session', async () => { + const s = setup(), c = await s.challenge(), other = setup() + expect(await other.proof.proof(s.request(c))).toEqual({ status: 'unprovable' }) + s.proof.close(); expect(await s.proof.proof(s.request(c))).toEqual({ status: 'unprovable' }) + expect((await s.proof.challenge({ pairingId: id })).status).toBe('unprovable') + }) + it.each(['setActive', 'changePeer'] as const)('refuses changed approval/identity: %s', async effect => { + const s = setup(), c = await s.challenge(); s[effect]() + expect(await s.proof.proof(s.request(c))).toEqual({ status: 'expired' }); expect(s.pairings.associate).not.toHaveBeenCalled() + }) + it('bounds suspended inspection and cannot allocate after it outlives the connection', async () => { + const s = setup(); let resolve!: (v: Awaited>) => void + vi.mocked(s.pairings.inspect).mockImplementationOnce(() => new Promise(r => { resolve = r })) + const old = s.proof.challenge({ pairingId: id }) + const overlapping = s.proof.challenge({ pairingId: id }) + expect(s.pairings.inspect).toHaveBeenCalledTimes(1) + expect(await overlapping).toEqual({ status: 'unprovable' }) + s.proof.close() + resolve({ status: 'eligible', target: { pairingId: id, sshPublicKeyB64: Buffer.alloc(32).toString('base64'), entryVersion: 'old' } }) + expect(await old).toEqual({ status: 'expired' }) + }) + it('consumes before a suspended publication and fences its late completion', async () => { + const s = setup(), c = await s.challenge(); let release!: () => void + vi.mocked(s.pairings.associate).mockImplementationOnce(async (_t, _p, current) => { + await new Promise(r => { release = r }); return { status: current() ? 'associated' : 'expired' } + }) + const first = s.proof.proof(s.request(c)); expect(await s.proof.proof(s.request(c))).toEqual({ status: 'unprovable' }) + s.proof.close(); release(); expect(await first).toEqual({ status: 'expired' }) + }) + it('a replacement challenge cancels an older in-flight publication', async () => { + const s = setup(), c = await s.challenge(); let release!: () => void + vi.mocked(s.pairings.associate).mockImplementationOnce(async (_t, _p, current) => { + await new Promise(r => { release = r }); return { status: current() ? 'associated' : 'expired' } + }) + const old = s.proof.proof(s.request(c)); const next = await s.challenge(); release() + expect(await old).toEqual({ status: 'expired' }) + expect(await s.proof.proof(s.request(next))).toEqual({ status: 'associated' }) + }) + it('rejects extra caller identity/key fields before inspection', async () => { + const s = setup(); expect(await s.proof.challenge({ pairingId: id, key: 'override' })).toEqual({ status: 'unprovable' }) + expect(s.pairings.inspect).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/remote/relay-pairing-proof.ts b/src/main/remote/relay-pairing-proof.ts new file mode 100644 index 0000000000..8caae317fd --- /dev/null +++ b/src/main/remote/relay-pairing-proof.ts @@ -0,0 +1,78 @@ +import { createPublicKey, randomBytes, randomUUID, verify } from 'crypto' +import { performance } from 'perf_hooks' +import { + RELAY_PAIRING_KEY, RELAY_PAIRING_SIGNATURE, RELAY_PAIRING_UUID, RELAY_PAIRING_PROOF_TTL_MS, + relayPairingChallengeText, type RelayPairingChallenge, type RelayPairingChallengeResult, + type RelayPairingProofResult, type RelayPairingStatus +} from '../../shared/relay-pairing-proof' + +export interface LegacyRelayPairingTarget { pairingId: string; sshPublicKeyB64: string; entryVersion: string } +export type LegacyRelayPairingInspection = { status: RelayPairingStatus } | { status: 'eligible'; target: LegacyRelayPairingTarget } +/** Local pairing mutations only; neither method changes the approved-key store. */ +export interface LegacyRelayPairings { + inspect(pairingId: string, peerKey: string): Promise + associate(target: LegacyRelayPairingTarget, peerKey: string, current: () => boolean): Promise +} + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null +} + +/** One ephemeral, single-use proof belongs to one immutable approved relay connection. */ +export function createRelayPairingProof(options: { + hostKey: string; peerKey(): string | null; current(): boolean; pairings: LegacyRelayPairings + now?: () => number; monotonicNow?: () => number +}) { + const now = options.now ?? Date.now + const monotonic = options.monotonicNow ?? (() => performance.now()) + let closed = false + let generation = 0 + let inspecting = false + let pending: { challenge: RelayPairingChallenge; target: LegacyRelayPairingTarget; deadline: number } | null = null + const live = () => !closed && options.current() + return { + close(): void { closed = true; generation++; pending = null }, + async challenge(params: unknown): Promise { + if (inspecting) return { status: 'unprovable' } // no unbounded queued disk inspections + const request = record(params) + const own = ++generation + pending = null + const peerKey = options.peerKey() + if (!request || Object.keys(request).length !== 1 || typeof request.pairingId !== 'string' || + !RELAY_PAIRING_UUID.test(request.pairingId) || !live() || !peerKey || + !RELAY_PAIRING_KEY.test(peerKey) || !RELAY_PAIRING_KEY.test(options.hostKey)) return { status: 'unprovable' } + let found: LegacyRelayPairingInspection + inspecting = true + try { found = await options.pairings.inspect(request.pairingId, peerKey) } + finally { inspecting = false } + if (!live() || own !== generation || options.peerKey() !== peerKey) return { status: 'expired' } + if (found.status !== 'eligible') return found + const challenge: RelayPairingChallenge = { + version: 1, challengeId: randomUUID(), pairingId: request.pairingId, hostPublicKeyB64: options.hostKey, + peerPublicKeyB64: peerKey, sshPublicKeyB64: found.target.sshPublicKeyB64, + nonceB64: randomBytes(32).toString('base64'), expiresAtMs: now() + RELAY_PAIRING_PROOF_TTL_MS + } + relayPairingChallengeText(challenge) + Object.freeze(challenge) + pending = { challenge, target: { ...found.target }, deadline: monotonic() + RELAY_PAIRING_PROOF_TTL_MS } + return { status: 'challenge', challenge } + }, + async proof(params: unknown): Promise { + const held = pending + pending = null // malformed/failed/concurrent attempts consume before ANY await + const request = record(params) + if (!held || !request || Object.keys(request).length !== 2 || request.challengeId !== held.challenge.challengeId || + typeof request.signatureB64 !== 'string' || !RELAY_PAIRING_SIGNATURE.test(request.signatureB64)) return { status: 'unprovable' } + const own = generation + const current = () => live() && generation === own && options.peerKey() === held.challenge.peerPublicKeyB64 && + now() < held.challenge.expiresAtMs && monotonic() < held.deadline + if (!current()) return { status: 'expired' } + const key = createPublicKey({ format: 'der', type: 'spki', key: Buffer.concat([ + Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(held.challenge.sshPublicKeyB64, 'base64') + ]) }) + if (!verify(null, Buffer.from(relayPairingChallengeText(held.challenge), 'utf8'), key, + Buffer.from(request.signatureB64, 'base64'))) return { status: 'unprovable' } + return options.pairings.associate(held.target, held.challenge.peerPublicKeyB64, current) + } + } +} diff --git a/src/main/remote/remote-security.test.ts b/src/main/remote/remote-security.test.ts index 623a1a0dea..0de3b4567a 100644 --- a/src/main/remote/remote-security.test.ts +++ b/src/main/remote/remote-security.test.ts @@ -5,7 +5,7 @@ // R4 — killing a stream forgets it in the SAME synchronous turn, so a late Input frame for that // streamId can never be written into a session that is already released. import { describe, expect, it, vi } from 'vitest' -import { createHostHandlers, type HostFsOps, type HostPtyManager, type HostRelaySocket } from './host-service' +import { createHostHandlers, type HostFsOps, type HostNewSessions, type HostPtyManager, type HostRelaySocket, type HostRemoteNodes } from './host-service' import { genKeyPair, deriveSharedKey, sasFromSharedKey, publicKeyToB64 } from './e2ee' import { connectRelay, type RelaySocket, type RelayTransport } from './relay-socket' import { OP, type Frame } from './framing' @@ -117,8 +117,8 @@ describe('projects.list serves the host projects blob', () => { const listProjects = vi.fn(async () => 'FIXTURE') const handlers = createHostHandlers(pty, socket, fs, () => [], listProjects) handlers.onRpc({ id: '1', method: 'projects.list', params: undefined }) - await Promise.resolve() - await Promise.resolve() + // A macrotask: the answer waits for the blob AND the LAN report (A74-refresh) to settle. + await new Promise((r) => setTimeout(r, 0)) expect(listProjects).toHaveBeenCalledTimes(1) expect(responses).toEqual([{ id: '1', ok: true, body: { output: 'FIXTURE' } }]) }) @@ -127,8 +127,7 @@ describe('projects.list serves the host projects blob', () => { const { socket, responses, fs, pty } = makeHostFakes() const handlers = createHostHandlers(pty, socket, fs, () => []) handlers.onRpc({ id: '2', method: 'projects.list', params: undefined }) - await Promise.resolve() - await Promise.resolve() + await new Promise((r) => setTimeout(r, 0)) expect(responses).toEqual([{ id: '2', ok: true, body: { output: '' } }]) }) }) @@ -354,30 +353,45 @@ describe('pty.attach forwards the real pty size as OP.Resized', () => { // itself — its emulator swallows the gesture — so it asks the host to write it into the stream's // pty, which IS a tmux client. `lines` and the stream target both come off the wire. describe('pty.scroll drives tmux through the session pty', () => { - const attach = (handlers: ReturnType): void => { + const flush = async () => { await new Promise(resolve => setImmediate(resolve)) } + const attach = async (handlers: ReturnType): Promise => { handlers.onRpc({ id: 'a', method: 'pty.attach', params: { nodeId: 'node-a', cols: 80, rows: 24 } }) + await flush() + } + const tmuxViewer = (pty: HostPtyManager): void => { + pty.scrollAttached = async (clientId, sessionId, up, lines, _capture, current) => { + if (!current()) return { status: 'refused', message: 'detached' } + for (let i = 0; i < lines; i++) pty.write(clientId, sessionId, `\x1b[<${up ? 64 : 65};1;1M`) + return { status: 'input' } + } } const writes = (pty: HostPtyManager): string[] => (pty.write as ReturnType).mock.calls.map((c) => c[2] as string) - it('writes one SGR wheel event per notch, up or down', () => { + it('writes one SGR wheel event per notch, up or down', async () => { const { socket, fs, pty } = makeHostFakes() + tmuxViewer(pty) const handlers = createHostHandlers(pty, socket, fs, () => ['/work']) - attach(handlers) + await attach(handlers) handlers.onRpc({ id: '1', method: 'pty.scroll', params: { streamId: 1, dir: 'up', lines: 3 } }) + await flush() expect(writes(pty)).toEqual(['\x1b[<64;1;1M', '\x1b[<64;1;1M', '\x1b[<64;1;1M']) handlers.onRpc({ id: '2', method: 'pty.scroll', params: { streamId: 1, dir: 'down', lines: 1 } }) + await flush() expect(writes(pty).at(-1)).toBe('\x1b[<65;1;1M') }) - it('clamps a hostile `lines` (it arrives from a remote client) and ignores an unknown stream', () => { + it('clamps a hostile `lines` (it arrives from a remote client) and ignores an unknown stream', async () => { const { socket, responses, fs, pty } = makeHostFakes() + tmuxViewer(pty) const handlers = createHostHandlers(pty, socket, fs, () => ['/work']) - attach(handlers) + await attach(handlers) handlers.onRpc({ id: '1', method: 'pty.scroll', params: { streamId: 1, dir: 'up', lines: 1e9 } }) + await flush() expect(writes(pty)).toHaveLength(20) // capped, not a million writes ;(pty.write as ReturnType).mockClear() handlers.onRpc({ id: '2', method: 'pty.scroll', params: { streamId: 1, lines: -5 } }) + await flush() expect(writes(pty)).toHaveLength(1) // floored at one notch ;(pty.write as ReturnType).mockClear() // An unknown streamId is a no-op, and still answers — never a hang. @@ -668,3 +682,275 @@ describe('remoteViewer presence reporting', () => { expect(base.responses.at(-1)).toMatchObject({ id: 'k', ok: true }) }) }) + +// Audit A09: a node of an SSH project lives on ANOTHER host. The relay used to attach it to the +// desktop's LOCAL tmux — creating a phantom `nt-` here and offering to resume the agent on the +// wrong machine. It is now attached over the project's ControlMaster, or refused. +describe('pty.attach of an SSH-project node never runs locally', () => { + const sshRemote = { + controlPath: '/tmp/cm.sock', + conn: { host: 'box', user: 'me' }, + remoteCwd: '~/repo', + hookEndpointPath: '/home/me/.nodeterm/hook.env' + } + const handlersWith = ( + pty: HostPtyManager, + socket: HostRelaySocket, + fs: HostFsOps, + resolve: HostRemoteNodes['resolve'] + ): ReturnType => { + // This group pins the older-manager compatibility route; the core-route refusal is above. + delete pty.prepareRelayAttach + return createHostHandlers( + pty, socket, fs, () => ['/work'], + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + { resolve } + ) + } + const addRemote = (pty: HostPtyManager): HostPtyManager => + Object.assign(pty, { + sessionExistsOver: vi.fn(async () => false), + captureSnapshotOver: vi.fn(async () => 'REMOTE SCREEN') + }) + + it('attaches over the master, requireRemote, with the host freshness and snapshot', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + addRemote(pty) + const handlers = handlersWith(pty, socket, fs, (id) => (id === 'node-r' ? { where: 'me@box', sshRemote } : null)) + handlers.onRpc({ id: 'r', method: 'pty.attach', params: { nodeId: 'node-r', cols: 90, rows: 30 } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect(responses[0]).toMatchObject({ ok: true, body: { fresh: true } }) + expect(pty.sessionExists).not.toHaveBeenCalled() + expect(pty.captureSnapshot).not.toHaveBeenCalled() + expect(pty.sessionExistsOver).toHaveBeenCalledWith('node-r', sshRemote) + expect(pty.captureSnapshotOver).toHaveBeenCalledWith('node-r', sshRemote) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ + cols: 90, + rows: 30, + sshRemote, + requireRemote: true + }) + }) + + it('refuses, naming the host, when the project is not connected — nothing is attached', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + addRemote(pty) + const handlers = handlersWith(pty, socket, fs, () => ({ where: 'me@box' })) + handlers.onRpc({ id: 'r', method: 'pty.attach', params: { nodeId: 'node-r', cols: 80, rows: 24 } }) + await vi.waitFor(() => expect(responses.length).toBe(1)) + expect(responses[0]).toMatchObject({ ok: false, body: { message: expect.stringContaining('me@box') } }) + await new Promise((r) => setTimeout(r, 10)) + expect(pty.attachDetached).not.toHaveBeenCalled() + expect(pty.sessionExists).not.toHaveBeenCalled() + }) + + it('refuses rather than falls back when the pty manager cannot reach remote hosts', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + const handlers = handlersWith(pty, socket, fs, () => ({ where: 'me@box', sshRemote })) + handlers.onRpc({ id: 'r', method: 'pty.attach', params: { nodeId: 'node-r', cols: 80, rows: 24 } }) + await vi.waitFor(() => expect(responses.length).toBe(1)) + expect(responses[0].ok).toBe(false) + expect(pty.attachDetached).not.toHaveBeenCalled() + }) + + it('a local node is attached exactly as before', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + addRemote(pty) + const handlers = handlersWith(pty, socket, fs, () => null) + handlers.onRpc({ id: 'l', method: 'pty.attach', params: { nodeId: 'node-l', cols: 80, rows: 24 } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect(responses[0].ok).toBe(true) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + expect(pty.sessionExistsOver).not.toHaveBeenCalled() + }) +}) + +// Audit A33: a session the PHONE starts is created where the desktop would create it — the host +// resolves the project's folder and the account from its own registry — and only when this attach +// is the one creating it. On Windows the phone's `cd`/env launch prefix cannot work at all. +describe('pty.attach creates a phone-started session in its project', () => { + const newSessions = (resolve: HostNewSessions['resolve']): HostNewSessions => ({ resolve }) + const handlersWith = (pty: HostPtyManager, socket: HostRelaySocket, fs: HostFsOps, ns: HostNewSessions) => { + // Exercise the retained prepareDetachedAttach fallback directly, without a core-route fake + // bypassing it. Real core local preparation is covered by PtyManager's tests. + delete pty.prepareRelayAttach + return createHostHandlers( + pty, socket, fs, () => ['/work'], + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + undefined, ns + ) + } + + it('applies the host-resolved cwd/account/agent/owner when the attach creates the session', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockResolvedValue(false) + const resolve = vi.fn(() => ({ + cwd: 'C:\\repo', + accountId: 'acct-1', + agentId: 'claude' as const, + ownerProjectId: 'p1' + })) + const handlers = handlersWith(pty, socket, fs, newSessions(resolve)) + handlers.onRpc({ + id: 'n', + method: 'pty.attach', + params: { nodeId: 'term-new-1', cols: 80, rows: 24, projectId: 'p1', accountId: 'acct-1', agentId: 'claude' } + }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect(resolve).toHaveBeenCalledWith({ projectId: 'p1', accountId: 'acct-1', agentId: 'claude' }) + // The agent is what gives the created session its agent-gated hook env, and the owner is what + // makes its pane messageable (audit A72) — both reach the spawn, exactly once, on the create. + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ + cols: 80, rows: 24, cwd: 'C:\\repo', accountId: 'acct-1', agentId: 'claude', ownerProjectId: 'p1' + }) + }) + + // Everything the resolver answers — the pane OWNER above all — is create-only. A join must not + // claim a live pane for the phone's project (agents/pane-ownership.ts: ownership is recorded on a + // genuine fresh spawn only), and "could not tell" is a join, never a create. + const RESOLVED = { cwd: '/repo', agentId: 'claude' as const, ownerProjectId: 'p1' } + + it('uses saved cold host facts instead of conflicting wire launch hints', async () => { + const { socket, fs, pty } = makeHostFakes() + vi.mocked(pty.sessionExists).mockResolvedValue(false) + const resolve = vi.fn(() => ({ cwd: '/wrong', ownerProjectId: 'wrong' })) + const handlers = handlersWith(pty, socket, fs, { resolve, resolveNode: () => RESOLVED }) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'saved', projectId: 'wrong', agentId: 'codex' } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect(resolve).not.toHaveBeenCalled() + expect(vi.mocked(pty.attachDetached).mock.calls[0][2]).toEqual({ cols: 80, rows: 24, ...RESOLVED }) + }) + + it('keeps known ineligible nodes bare rather than accepting a replacement project hint', async () => { + const { socket, fs, pty } = makeHostFakes() + vi.mocked(pty.sessionExists).mockResolvedValue(false) + const resolve = vi.fn(() => RESOLVED) + const handlers = handlersWith(pty, socket, fs, { resolve, resolveNode: () => null }) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'ineligible', projectId: 'p1' } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect(resolve).not.toHaveBeenCalled() + expect(vi.mocked(pty.attachDetached).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + }) + + it('finishes preparation before responding and commits after SnapshotEnd without losing the launch line', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + vi.mocked(pty.sessionExists).mockResolvedValue(false) + let finish!: () => void + const ready = new Promise((resolve) => { finish = resolve }) + const commit = vi.fn(() => 'prepared-session') + pty.prepareDetachedAttach = vi.fn(async () => { await ready; return { fresh: true, attach: commit } }) + const handlers = handlersWith(pty, socket, fs, newSessions(() => RESOLVED)) + const phases: string[] = [] + socket.sendFrame = (op, streamId) => { + if (op === OP.SnapshotEnd) { + phases.push('snapshot-end') + // Network input runs on the next turn, like the actual encrypted relay socket. + queueMicrotask(() => handlers.onFrame({ op: OP.Input, streamId, seq: 0, + payload: new TextEncoder().encode('claude --resume saved\r') })) + } + return true + } + commit.mockImplementation(() => { phases.push('attach'); return 'prepared-session' }) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'saved', projectId: 'p1' } }) + await vi.waitFor(() => expect(pty.prepareDetachedAttach).toHaveBeenCalled()) + expect(responses).toEqual([]) + expect(commit).not.toHaveBeenCalled() + finish() + await vi.waitFor(() => expect(pty.write).toHaveBeenCalledWith(null, 'prepared-session', 'claude --resume saved\r')) + expect(phases).toEqual(['snapshot-end', 'attach']) + expect(responses[0]).toEqual({ id: 'n', ok: true, body: { streamId: 1, fresh: true } }) + expect(pty.attachDetached).not.toHaveBeenCalled() + }) + + it('discards a preparation canceled while settings resolve without spawning or sending ready frames', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + vi.mocked(pty.sessionExists).mockResolvedValue(false) + let finish!: () => void + const waiting = new Promise((resolve) => { finish = resolve }) + const commit = vi.fn(() => 'prepared') + pty.prepareDetachedAttach = vi.fn(async () => { await waiting; return { fresh: true, attach: commit } }) + const frames = vi.fn(() => true) + socket.sendFrame = frames + const handlers = handlersWith(pty, socket, fs, newSessions(() => RESOLVED)) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'saved', projectId: 'p1' } }) + await vi.waitFor(() => expect(pty.prepareDetachedAttach).toHaveBeenCalled()) + handlers.closeAll() + finish() + await new Promise((r) => setTimeout(r, 0)) + expect(commit).not.toHaveBeenCalled() + expect(responses).toEqual([]) + expect(frames).not.toHaveBeenCalled() + }) + + it('reports the prepared warm verdict if another owner creates the pane while settings resolve', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + vi.mocked(pty.sessionExists).mockResolvedValue(false) + pty.prepareDetachedAttach = vi.fn(async () => ({ fresh: false, attach: () => 'joined' })) + const handlers = handlersWith(pty, socket, fs, newSessions(() => RESOLVED)) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'saved', projectId: 'p1' } }) + await vi.waitFor(() => expect(responses).toHaveLength(1)) + expect(responses[0]).toEqual({ id: 'n', ok: true, body: { streamId: 1, fresh: false } }) + }) + + it('carries saved launch facts into preparation if an initially warm pane disappears before commit', async () => { + const { socket, responses, fs, pty } = makeHostFakes() + vi.mocked(pty.sessionExists).mockResolvedValue(true) + pty.prepareDetachedAttach = vi.fn(async () => ({ fresh: true, attach: () => 'recreated' })) + const handlers = handlersWith(pty, socket, fs, { resolve: () => null, resolveNode: () => RESOLVED }) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'saved' } }) + await vi.waitFor(() => expect(responses).toHaveLength(1)) + expect(pty.prepareDetachedAttach).toHaveBeenCalledWith('saved', { cols: 80, rows: 24, ...RESOLVED }) + expect(responses[0]).toEqual({ id: 'n', ok: true, body: { streamId: 1, fresh: true } }) + }) + + it('applies nothing when the session already exists (a join, not a create)', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockResolvedValue(true) + const handlers = handlersWith(pty, socket, fs, newSessions(() => RESOLVED)) + handlers.onRpc({ + id: 'n', + method: 'pty.attach', + params: { nodeId: 'term-a', cols: 80, rows: 24, projectId: 'p1', agentId: 'claude' } + }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + }) + + it('a probe that FAILS is a join: no owner, no agent, no folder', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockRejectedValue(new Error('EAGAIN')) + const handlers = handlersWith(pty, socket, fs, newSessions(() => RESOLVED)) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'term-c', cols: 80, rows: 24, projectId: 'p1' } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + }) + + it('a probe that never answers is a join once its budget runs out', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockReturnValue(new Promise(() => {})) + const handlers = handlersWith(pty, socket, fs, newSessions(() => RESOLVED)) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'term-d', cols: 80, rows: 24, projectId: 'p1' } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled(), { timeout: 3000 }) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + }) + + it('an older phone that names no project creates exactly as before, owner-less', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockResolvedValue(false) + const resolve = vi.fn(() => RESOLVED) + const handlers = handlersWith(pty, socket, fs, newSessions(resolve)) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'term-e', cols: 80, rows: 24 } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect(resolve).not.toHaveBeenCalled() + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + }) + + it('an unknown project (resolver says null) creates exactly as before', async () => { + const { socket, fs, pty } = makeHostFakes() + ;(pty.sessionExists as ReturnType).mockResolvedValue(false) + const handlers = handlersWith(pty, socket, fs, newSessions(() => null)) + handlers.onRpc({ id: 'n', method: 'pty.attach', params: { nodeId: 'term-b', cols: 80, rows: 24, projectId: 'nope' } }) + await vi.waitFor(() => expect(pty.attachDetached).toHaveBeenCalled()) + expect((pty.attachDetached as ReturnType).mock.calls[0][2]).toEqual({ cols: 80, rows: 24 }) + }) +}) diff --git a/src/main/remote/revocation.ts b/src/main/remote/revocation.ts index cc3b2efd0e..badec171e3 100644 --- a/src/main/remote/revocation.ts +++ b/src/main/remote/revocation.ts @@ -42,7 +42,9 @@ export interface RevocationDeps { */ export interface RevokeResult { /** - * `true` iff the unpin was loaded and written to disk. `false` means the pin may SURVIVE — the + * `true` iff the unpin landed: it was written to disk, or the key was not pinned in the list read + * from disk, so there was nothing to write (`updateApprovedDevices` skips a write that changes + * nothing). `false` means the pin may SURVIVE — the * real adapter's `saveApprovedDevices` does temp+rename, so a failed write leaves the OLD still- * pinned file byte-for-byte intact — so the revoked peer could reconnect and auto-approve with no * SAS. The caller MUST retry and MUST NOT show a "Removed" success. diff --git a/src/main/remote/standing-host.test.ts b/src/main/remote/standing-host.test.ts index 63c2509566..4809cbc67c 100644 --- a/src/main/remote/standing-host.test.ts +++ b/src/main/remote/standing-host.test.ts @@ -80,6 +80,9 @@ vi.mock('./approved-devices', () => { return { PIN_ROLES: ['phone', 'guest', 'joinedHost'], phonePins: stores.phone, + loadApprovedDevices: () => stores.phone.load(), + saveApprovedDevices: (s: ApprovedDevices) => stores.phone.save(s), + updateApprovedDevices: (u: (s: ApprovedDevices) => ApprovedDevices) => stores.phone.update(u), guestPins: stores.guest, joinedHostPins: stores.joinedHost, pinStore: (r: string) => stores[r], @@ -88,7 +91,15 @@ vi.mock('./approved-devices', () => { }) vi.mock('./e2ee', () => ({ publicKeyToB64: (k: Uint8Array) => Buffer.from(k).toString('base64') })) -const sessions: Array<{ opts: HostSessionOptions; session: HostSession; closed: number }> = [] +const sessions: Array<{ + opts: HostSessionOptions + session: HostSession + closed: number + /** The bridged phone's box key (null before its handshake, as on a real idle listener). */ + peer: string | null + /** Make close() throw, as a teardown failure would. */ + closeThrows?: boolean +}> = [] // Swappable: a locked OS keyring makes the host key unreadable, and loading it REJECTS rather than // rotating the pinned identity (host-identity.ts). The standing host must handle that, loudly. @@ -103,15 +114,16 @@ vi.mock('./host-service', () => ({ return hostKeys }, connectHostSession: (opts: HostSessionOptions): HostSession => { - const entry = { opts, closed: 0, session: null as unknown as HostSession } + const entry: (typeof sessions)[number] = { opts, closed: 0, peer: 'phone-pub', session: null as unknown as HostSession } entry.session = { approve: vi.fn(), isApproved: () => false, sas: () => '12345', // A real relay socket close() is "intentional" and does NOT fire onClose — modelled here. - peerPublicKeyB64: () => 'phone-pub', + peerPublicKeyB64: () => entry.peer, close: () => { entry.closed += 1 + if (entry.closeThrows) throw new Error('teardown failed') } } sessions.push(entry) @@ -119,7 +131,20 @@ vi.mock('./host-service', () => ({ } })) -import { initStandingHost, tokenTtlMs } from './standing-host' +// The peer revoker also cuts relay-host (peer desktop) sessions; that host is not under test here. +const relayHostKills = vi.fn((_pub: string) => {}) +vi.mock('./relay-host', () => ({ killRelayHostsByPeerKey: (pub: string) => relayHostKills(pub) })) + +import { + initStandingHost, + tokenTtlMs, + killStandingHostSessionsByPeerKey, + rememberRevokedStandingPhone, + resetRevokedPhonesForTests, + REVOKED_PHONE_DENY_MS, + type StandingHostOptions +} from './standing-host' +import { createPeerRevoker } from './peer-revoker' import { revokeAllPhones, revokePeerKey } from './peer-revoke' import { IPC } from '../../shared/ipc' @@ -181,7 +206,10 @@ function phones(): number { const sentToWin: Array<{ channel: string; args: unknown[] }> = [] let sender: unknown -function makeHost() { +// Every host a test made, stopped after it even when an assertion threw first: a running host stays +// registered for revocation, so one left running would answer a later test's revoke. +const hosts: Array<{ stop(): void }> = [] +function makeHost(options: StandingHostOptions = {}) { const win = { isDestroyed: () => false, webContents: { @@ -189,7 +217,9 @@ function makeHost() { } } sender = win.webContents - return initStandingHost(win as never, {} as never, () => ({ phoneAccessEnabled: true }) as never) + const host = initStandingHost(win as never, {} as never, () => ({ phoneAccessEnabled: true }) as never, undefined, undefined, options) + hosts.push(host) + return host } /** The pending-approval id the host just surfaced to the human (SAS dialog). */ @@ -207,6 +237,8 @@ beforeEach(() => { disk = { pubkeys: [] } for (const k of Object.keys(otherPins)) delete otherPins[k] persist.mockImplementation(async (update) => { disk = update(disk) }) + relayHostKills.mockReset() + resetRevokedPhonesForTests() keyError = null storedEntitlement = 'entitlement' advertisementsRemoved = 0 @@ -215,12 +247,26 @@ beforeEach(() => { }) afterEach(() => { + for (const host of hosts.splice(0)) host.stop() + vi.useRealTimers() for (const p of presenceHub.peers()) presenceHub.leave(p.clientId) vi.unstubAllGlobals() resetPlatformForTests() }) describe('standing host presence peers', () => { + it('forwards the exact optional legacy pairing store into the real session factory', async () => { + const pairings: NonNullable = { + inspect: vi.fn(async () => ({ status: 'unprovable' as const })), + associate: vi.fn(async () => ({ status: 'associated' as const })) + } + const host = makeHost({ legacyRelayPairings: pairings }) + host.setEnabled(true); await settle() + expect(sessions).toHaveLength(1) + expect(sessions[0].opts.legacyRelayPairings).toBe(pairings) + expect(await sessions[0].opts.legacyRelayPairings!.inspect('fixture-id', 'fixture-peer')).toEqual({ status: 'unprovable' }) + expect(pairings.inspect).toHaveBeenCalledWith('fixture-id', 'fixture-peer') + }) it('a bridged relay client joins as a cursorless phone peer and leaves when the socket drops', async () => { const host = makeHost() host.setEnabled(true) @@ -378,6 +424,338 @@ describe('standing phone approval lifecycle (#819)', () => { }) }) +describe('revoking a phone cuts its live relay session (audit A07-revoke)', () => { + // Unpinning a key only refuses its NEXT handshake: a phone that is connected while it is forgotten + // would keep serving terminals, files and the canvas until its socket dropped on its own. The + // revoke (Settings → Phone → Revoke, through pairing-service's revokeRelayKey, or + // `remote:revoke-peer`) goes through the peer revoker, which unpins and then closes it. + + /** Bridge the newest idle listener to `peer`, as a phone completing its handshake does. */ + async function bridge(peer: string) { + const entry = sessions.at(-1)! + entry.peer = peer + entry.opts.onPeerReady(entry.session) + await settle() + const replacement = sessions.at(-1)! + expect(replacement).not.toBe(entry) // the pool opened a fresh listener for the next phone… + replacement.peer = null // …which has no phone yet + return entry + } + function pendingFor(pub: string) { + return sentToWin.filter((s) => s.channel === IPC.remoteHostPeerPending).map((s) => s.args[0] as { id: string; pub: string }) + .filter((m) => m.pub === pub) + } + + it('closes every session of the revoked phone, unpins it, and leaves other phones alone', async () => { + disk = { pubkeys: ['phone-A', 'phone-B'] } + const host = makeHost() + host.setEnabled(true) + await settle() + const a1 = await bridge('phone-A') + const b = await bridge('phone-B') + const a2 = await bridge('phone-A') // the same phone, a second concurrent session + const idle = sessions.at(-1)! + expect([a1, b, a2].map((e) => (e.session.approve as ReturnType).mock.calls.length)).toEqual([1, 1, 1]) + expect(phones()).toBe(3) + + expect(await createPeerRevoker().revoke('phone-A')).toEqual({ persisted: true, killed: true }) + + expect(disk.pubkeys).toEqual(['phone-B']) + expect([a1.closed, a2.closed]).toEqual([1, 1]) + expect([b.closed, idle.closed]).toEqual([0, 0]) + expect(phones()).toBe(1) // the revoked phone left the facepile; the other is still there + expect(relayHostKills).toHaveBeenCalledWith('phone-A') // and any peer-desktop session of that key + + // A reconnect from the revoked phone is no longer auto-approved (the review of A07-revoke covers + // what it gets instead, below). + const again = await bridge('phone-A') + expect(again.session.approve).not.toHaveBeenCalled() + host.stop() + }) + + it('withdraws a pending approval, including one that outlived its socket (#819)', async () => { + const host = makeHost() + host.setEnabled(true) + await settle() + const live = await bridge('phone-A') + const liveMsg = pendingFor('phone-A')[0] + expect(killStandingHostSessionsByPeerKey('phone-A')).toBe(1) + expect(live.closed).toBe(1) + expect(await ipc[IPC.remotePhoneApprove]({ sender }, liveMsg)).toEqual({ status: 'stale' }) + + const gone = await bridge('phone-C') + const goneMsg = pendingFor('phone-C')[0] + gone.opts.onClose() // the browse socket closed; its consent record is kept for the human + expect(killStandingHostSessionsByPeerKey('phone-C')).toBe(0) // no session left to close… + expect(await ipc[IPC.remotePhoneApprove]({ sender }, goneMsg)).toEqual({ status: 'stale' }) // …but no re-pin either + const cleared = sentToWin.filter((s) => s.channel === IPC.remoteHostPeerPendingCleared).map((s) => (s.args[0] as { id: string }).id) + expect(cleared).toEqual(expect.arrayContaining([liveMsg.id, goneMsg.id])) + expect(persist).not.toHaveBeenCalled() + host.stop() + }) + + it('keeps cutting when one teardown throws, and the revoke reports the cut as unconfirmed', async () => { + disk = { pubkeys: ['phone-A'] } + const host = makeHost() + host.setEnabled(true) + await settle() + const first = await bridge('phone-A') + const second = await bridge('phone-A') + first.closeThrows = true + expect(await createPeerRevoker().revoke('phone-A')).toEqual({ persisted: true, killed: false }) + expect([first.closed, second.closed]).toEqual([1, 1]) + expect(phones()).toBe(0) + host.stop() + }) + + it('a stopped host has nothing to cut', async () => { + disk = { pubkeys: ['phone-A'] } + const host = makeHost() + host.setEnabled(true) + await settle() + const a = await bridge('phone-A') + host.stop() + expect(a.closed).toBe(1) + expect(killStandingHostSessionsByPeerKey('phone-A')).toBe(0) + expect(a.closed).toBe(1) + }) +}) + +describe('a phone revoked during this run is refused without a dialog (review of A07-revoke)', () => { + // Cutting the session makes the phone redial (the Android client does so 1.5 s after a drop). With + // its key unpinned and its pairing gone, that handshake used to raise the SAS dialog for the phone + // the user had just removed, and approving it pinned the key again. It is now left unapproved, so + // every request it makes hears "Awaiting host approval.", and closed after REVOKED_PHONE_DENY_MS: + // what a human's Deny looks like to the phone, which then stops dialing on its own. + + async function bridge(peer: string) { + const entry = sessions.at(-1)! + entry.peer = peer + entry.opts.onPeerReady(entry.session) + await settle() + sessions.at(-1)!.peer = null // the replacement listener has no phone yet + return entry + } + const dialogsFor = (pub: string) => + sentToWin.filter((s) => s.channel === IPC.remoteHostPeerPending && (s.args[0] as { pub: string }).pub === pub) + + it('a redial after the revoke raises no dialog, stays unapproved, and is closed like a Deny', async () => { + vi.useFakeTimers() + disk = { pubkeys: ['phone-A'] } + const host = makeHost() + host.setEnabled(true) + await settle() + const live = await bridge('phone-A') + expect(await createPeerRevoker().revoke('phone-A')).toEqual({ persisted: true, killed: true }) + expect(live.closed).toBe(1) + + const again = await bridge('phone-A') + expect(dialogsFor('phone-A')).toEqual([]) // nothing on the desk to approve by mistake + expect(again.session.approve).not.toHaveBeenCalled() + expect(phones()).toBe(1) + // Not at once: the phone must first hear that it awaits approval, or it reads a plain failure. + vi.advanceTimersByTime(REVOKED_PHONE_DENY_MS - 1) + expect(again.closed).toBe(0) + vi.advanceTimersByTime(1) + expect(again.closed).toBe(1) + expect(phones()).toBe(0) + expect(disk.pubkeys).toEqual([]) // and nothing pinned it again + await settle() + expect(sessions.filter((e) => e.closed === 0)).toHaveLength(1) // the pool is topped back up + host.stop() + }) + + it('a revoke while remote access is off counts too', async () => { + vi.useFakeTimers() + expect(killStandingHostSessionsByPeerKey('phone-Z')).toBe(0) // no host runs + const host = makeHost() + host.setEnabled(true) + await settle() + const later = await bridge('phone-Z') + expect(dialogsFor('phone-Z')).toEqual([]) + vi.advanceTimersByTime(REVOKED_PHONE_DENY_MS) + expect(later.closed).toBe(1) + host.stop() + }) + + it('a pure revoke mark while hosting is off suppresses later SAS and permits explicit re-pair', async () => { + vi.useFakeTimers() + let paired = false + const host = makeHost({ pinPairedPhone: async (pub) => paired && pub === 'phone-A' }) + rememberRevokedStandingPhone('phone-A') + expect(sessions).toHaveLength(0) // the mark neither starts hosting nor tears a session down + host.setEnabled(true) + await settle() + const refused = await bridge('phone-A') + expect(dialogsFor('phone-A')).toEqual([]) + expect(refused.session.approve).not.toHaveBeenCalled() + vi.advanceTimersByTime(REVOKED_PHONE_DENY_MS) + expect(refused.closed).toBe(1) + + paired = true // a new explicit pairing recorded this key while relay access was off + const repaired = await bridge('phone-A') + expect(repaired.session.approve).toHaveBeenCalledOnce() + expect(repaired.closed).toBe(0) + expect(dialogsFor('phone-A')).toEqual([]) + const stranger = await bridge('phone-B') + expect(dialogsFor('phone-B')).toHaveLength(1) + expect(stranger.session.approve).not.toHaveBeenCalled() + vi.advanceTimersByTime(REVOKED_PHONE_DENY_MS) + expect([repaired.closed, stranger.closed]).toEqual([0, 0]) + host.stop() + }) + + it('pairing the phone again lets it in, and another phone still gets the dialog', async () => { + vi.useFakeTimers() + let paired = false + const host = makeHost({ pinPairedPhone: async () => paired }) + host.setEnabled(true) + await settle() + killStandingHostSessionsByPeerKey('phone-A') + + const stranger = await bridge('phone-B') + expect(dialogsFor('phone-B')).toHaveLength(1) + vi.advanceTimersByTime(REVOKED_PHONE_DENY_MS) + expect(stranger.closed).toBe(0) // only the revoked key is refused + + paired = true // re-paired with remote access off: the pairing recorded its key (A07-late) + const repaired = await bridge('phone-A') + expect(repaired.session.approve).toHaveBeenCalledOnce() + disk = { pubkeys: ['phone-A'] } // or re-paired with it on: the scan pinned it (A07) + paired = false + const pinned = await bridge('phone-A') + expect(pinned.session.approve).toHaveBeenCalledOnce() + vi.advanceTimersByTime(REVOKED_PHONE_DENY_MS) + expect([repaired.closed, pinned.closed]).toEqual([0, 0]) + expect(dialogsFor('phone-A')).toEqual([]) + host.stop() + }) + + it('a refused session that drops or is stopped first is not closed twice', async () => { + vi.useFakeTimers() + killStandingHostSessionsByPeerKey('phone-A') + const host = makeHost() + host.setEnabled(true) + await settle() + const dropped = await bridge('phone-A') + dropped.opts.onClose() // the phone hung up on its own + const stopped = await bridge('phone-A') + host.stop() + expect(stopped.closed).toBe(1) + vi.advanceTimersByTime(REVOKED_PHONE_DENY_MS) + expect([dropped.closed, stopped.closed]).toEqual([0, 1]) + expect(phones()).toBe(0) + }) +}) + +describe('a paired phone adopting the relay late is approved by its pairing, not a dialog (audit A07-late)', () => { + // Pairing recorded the phone's relay key but did not pin it (no relay leg at the scan). The phone + // adopts the relay over SSH later, and its first handshake here is usually made away from the desk. + function pending(): Array<{ pub: string }> { + return sentToWin.filter((s) => s.channel === IPC.remoteHostPeerPending).map((s) => s.args[0] as { pub: string }) + } + + it('approves the handshake silently when the pairing record pins the key', async () => { + const asked: string[] = [] + const host = makeHost({ pinPairedPhone: async (pub) => (asked.push(pub), true) }) + host.setEnabled(true) + await settle() + sessions[0].opts.onPeerReady(sessions[0].session) + await settle() + expect(asked).toEqual(['phone-pub']) + expect(sessions[0].session.approve).toHaveBeenCalledOnce() + expect(pending()).toEqual([]) + host.stop() + }) + + it('a key no pairing recorded, or a check that fails, gets the dialog as before', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + for (const pinPairedPhone of [ + async () => false, + async (): Promise => { + throw new Error('fixture') + } + ]) { + sentToWin.length = 0 + sessions.length = 0 + const host = makeHost({ pinPairedPhone }) + host.setEnabled(true) + await settle() + sessions[0].opts.onPeerReady(sessions[0].session) + await settle() + expect(sessions[0].session.approve).not.toHaveBeenCalled() + expect(pending().map((m) => m.pub)).toEqual(['phone-pub']) + host.stop() + } + warn.mockRestore() + }) + + it('an already pinned phone never reaches the check, and an unverified handshake never does', async () => { + const asked = vi.fn(async () => true) + disk = { pubkeys: ['phone-pub'] } + const host = makeHost({ pinPairedPhone: asked }) + host.setEnabled(true) + await settle() + sessions[0].opts.onPeerReady(sessions[0].session) + await settle() + expect(sessions[0].session.approve).toHaveBeenCalledOnce() + disk = { pubkeys: [] } + const unverified = sessions.at(-1)! + unverified.session.sas = () => null as unknown as string + unverified.opts.onPeerReady(unverified.session) + await settle() + expect(unverified.session.approve).not.toHaveBeenCalled() + expect(asked).not.toHaveBeenCalled() + host.stop() + }) + + it('hands connectHostSession a decision that settles only once it has approved or asked the human', async () => { + // connectHostSession holds the phone's requests until this settles (review of A07-late): a + // phone told "Awaiting host approval." while the late pin is still on disk gives up when it is + // a background check, and forgets that this computer approves it. + for (const paired of [true, false]) { + sentToWin.length = 0 + sessions.length = 0 + let answer!: (v: boolean) => void + const host = makeHost({ pinPairedPhone: () => new Promise((r) => (answer = r)) }) + host.setEnabled(true) + await settle() + const decision = sessions[0].opts.onPeerReady(sessions[0].session) + expect(decision).toBeInstanceOf(Promise) + let settled = false + void (decision as Promise).then(() => (settled = true)) + await settle() + expect(settled).toBe(false) // the late pin has not answered yet: still deciding + answer(paired) + await decision + if (paired) { + expect(sessions[0].session.approve).toHaveBeenCalledOnce() + expect(pending()).toEqual([]) + } else { + expect(sessions[0].session.approve).not.toHaveBeenCalled() + expect(pending().map((m) => m.pub)).toEqual(['phone-pub']) // the dialog is up before it settles + } + host.stop() + } + }) + + it('a phone revoked while the check is in flight is not approved', async () => { + let answer!: (v: boolean) => void + const host = makeHost({ pinPairedPhone: () => new Promise((r) => (answer = r)) }) + host.setEnabled(true) + await settle() + sessions[0].opts.onPeerReady(sessions[0].session) + await settle() + sessions.at(-1)!.peer = null // the replacement listener has no phone yet + expect(killStandingHostSessionsByPeerKey('phone-pub')).toBe(1) + answer(true) + await settle() + expect(sessions[0].session.approve).not.toHaveBeenCalled() + expect(sessions[0].closed).toBe(1) + host.stop() + }) +}) + describe('standing host: revocation and pin-store roles', () => { const pendingCount = (): number => sentToWin.filter((x) => x.channel === IPC.remoteHostPeerPending).length @@ -397,7 +775,7 @@ describe('standing host: revocation and pin-store roles', () => { expect(sessions[0].closed).toBe(1) expect(phones()).toBe(0) - // The same phone reconnects on a fresh listener: it must face the SAS prompt again. + // Android immediate-redial safety remains: an explicitly forgotten phone gets no new SAS prompt. const before = pendingCount() await settle() const next = sessions.at(-1)! @@ -405,7 +783,30 @@ describe('standing host: revocation and pin-store roles', () => { next.opts.onPeerReady(next.session) await settle() expect(next.session.approve).not.toHaveBeenCalled() - expect(pendingCount()).toBe(before + 1) + expect(pendingCount()).toBe(before) + host.stop() + }) + + it('canonical revoke cuts every matching session even when one close throws, leaving another phone untouched', async () => { + disk = { pubkeys: ['phone-A', 'phone-B'] } + const host = makeHost() + host.setEnabled(true) + await settle() + const bridge = async (pub: string) => { + const entry = sessions.at(-1)! + entry.peer = pub + entry.opts.onPeerReady(entry.session) + await settle() + sessions.at(-1)!.peer = null + return entry + } + const first = await bridge('phone-A') + const second = await bridge('phone-A') + const other = await bridge('phone-B') + first.closeThrows = true + expect(await revokePeerKey('phone-A', ['phone'])).toEqual({ persisted: true, killed: false }) + expect([first.closed, second.closed, other.closed]).toEqual([1, 1, 0]) + expect(disk.pubkeys).toEqual(['phone-B']) host.stop() }) diff --git a/src/main/remote/standing-host.ts b/src/main/remote/standing-host.ts index fec11203ab..86c664737d 100644 --- a/src/main/remote/standing-host.ts +++ b/src/main/remote/standing-host.ts @@ -11,7 +11,13 @@ // reconnect before expiry, and reconnect with bounded backoff on socket close; // - uses PIN-ONCE approval: the first connect from a given phone (its box public key) prompts // the host human via the shared SAS dialog; on approval the pubkey is pinned, so later -// connects auto-approve silently. +// connects auto-approve silently. A paired phone needs no dialog: its key is pinned at the scan +// when the pairing minted a relay leg (audit A07), and otherwise on its first handshake here while +// its pairing, which recorded the key, is still listed (`pinPairedPhone`, A07-late); +// - is cut on REVOCATION: forgetting a phone unpins its key and then closes the sessions it has +// open (`killStandingHostSessionsByPeerKey`, called by peer-revoker.ts). For the rest of the app +// run the forgotten phone's reconnects are then refused the way a Deny refuses them, with no +// dialog (`REVOKED_PHONE_DENY_MS`), unless pairing it again pinned or recorded its key. // // The heavy lifting (relay wiring, RPC/frame handlers, fs jail, canvas mirror, approval gate) is // shared with the interactive host via `connectHostSession`. Pin/lookup logic is the pure, @@ -35,6 +41,7 @@ import { type HostBridgeDeps, type HostSession } from './host-service' +import type { LegacyRelayPairings } from './relay-pairing-proof' import { currentCanvas, initHostCanvasHub, subscribeCanvas } from './host-canvas-hub' import { hostIdFromPublicKeyB64 } from './relay-id' import { removeRelayAdvertisement, writeRelayAdvertisement } from './relay-advertise' @@ -57,6 +64,16 @@ const MIN_REFRESH_MS = 15_000 const DEFAULT_TTL_MS = 120_000 // Bounded backoff for reconnect after a socket close / mint failure. const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15_000] +/** + * How long a phone revoked during this run is left unapproved before its session is closed (see + * `revokedThisRun`). Until then every request it makes is answered "Awaiting host approval.", and + * the close after that is what a human's Deny looks like to a phone: the Android client reads a + * close after it was told it awaits approval as a refusal and stops dialing on its own + * (RelayConnector → RelayApprovalGate). Closed at once, the phone may not have heard that yet and + * would read an ordinary failure, which it retries every few seconds. Long enough for its first + * request over a slow relay, short enough that the approval code it shows meanwhile is a flash. + */ +export const REVOKED_PHONE_DENY_MS = 3_000 interface HostTokenResponse { pairingToken: string @@ -232,6 +249,56 @@ export interface StandingHost { stop(): void } +// The revoke hook of every RUNNING standing host (in production there is one). Module-level, like +// relay-host.ts's `live` set, so the peer revoker (peer-revoker.ts) reaches it without depending on +// the order index.ts constructs the two in. +const runningHosts = new Set<(peerKeyB64: string) => number>() + +// The phone keys revoked during this app run (review of A07-revoke). Cutting a phone's session makes +// it redial (the Android client does so about 1.5 s after a drop while its screen is open), and with +// its key unpinned and its pairing gone, that handshake used to raise the SAS dialog for the phone +// the user had just removed, where approving it would pin the key again. Such a handshake is now +// refused without a dialog (`REVOKED_PHONE_DENY_MS`). Module-level, so a revoke while remote access +// is off counts too. Checked only after the pin and the paired-phone check, so pairing the phone +// again (which pins or records its key) lets it in. In memory on purpose: after a restart the phone +// gets the dialog again, as any unpinned phone does, and a desktop that cannot write the unpin keeps +// the device listed instead (pairing-service.ts `revokeDevice`). +const revokedThisRun = new Set() + +/** Remember an exact phone revoke even with hosting off; session teardown belongs to the caller. */ +export function rememberRevokedStandingPhone(peerKeyB64: string): void { + if (peerKeyB64) revokedThisRun.add(peerKeyB64) +} + +/** + * Close every standing-host relay session whose phone is `peerKeyB64`, and withdraw any approval + * dialog still pending for it. The kill half of revoking a phone: unpinning its key only refuses + * the NEXT handshake, while a session already open keeps serving terminals, files and the canvas + * (see revocation.ts). Sessions of every other key are untouched. Returns how many were closed. + * The key is remembered for the rest of the run (`revokedThisRun`), whether or not a host runs now. + */ +export function killStandingHostSessionsByPeerKey(peerKeyB64: string): number { + rememberRevokedStandingPhone(peerKeyB64) + let closed = 0 + for (const revokePeer of [...runningHosts]) closed += revokePeer(peerKeyB64) + return closed +} + +/** Test seam: forget every key revoked so far (the set otherwise lives as long as the process). */ +export function resetRevokedPhonesForTests(): void { + revokedThisRun.clear() +} + +export interface StandingHostOptions { + legacyRelayPairings?: LegacyRelayPairings + /** + * An unpinned phone completed the handshake: pin its key and answer true when a listed pairing + * recorded it (pairing-service.ts `approvePairedRelayKey`, A07-late), so the handshake is approved + * without the SAS dialog. False or a rejection ⇒ the dialog, as before. Absent ⇒ always the dialog. + */ + pinPairedPhone?(boxPublicKeyB64: string): Promise +} + /** * Wire the standing phone host. Idempotent to construct once; `setEnabled` / `syncFromSettings` * reconcile the live connection against (enabled && relay-allowed). @@ -241,7 +308,8 @@ export function initStandingHost( ptyManager: PtyManager, getSettings: () => Settings, listProjects: () => Promise = async () => '', - bridge: HostBridgeDeps = {} + bridge: HostBridgeDeps = {}, + options: StandingHostOptions = {} ): StandingHost { initHostCanvasHub() @@ -261,6 +329,8 @@ export function initStandingHost( approvalPub: string | null approvalId: string | null refreshTimer: ReturnType | null + /** A phone revoked during this run: the close that refuses it (see `REVOKED_PHONE_DENY_MS`). */ + denyTimer: ReturnType | null } let enabled = false @@ -296,6 +366,13 @@ export function initStandingHost( cleared: (id) => send(IPC.remoteHostPeerPendingCleared, { id }) }) + function clearDenyTimer(p: Pooled): void { + if (p.denyTimer) { + clearTimeout(p.denyTimer) + p.denyTimer = null + } + } + function removeFromPool(p: Pooled): void { if (p.approvalId) approvals.clear(p.approvalId) p.presence.leave() @@ -303,10 +380,35 @@ export function initStandingHost( clearTimeout(p.refreshTimer) p.refreshTimer = null } + clearDenyTimer(p) pool.delete(p) p.session.close() } + // A paired phone was revoked on this desktop (audit A07-revoke). Every pooled session with its + // key ends through removeFromPool, the path the human's "Deny" takes: presence leaves, the pending + // dialog clears, the served PTYs are killed and the socket closes. A session that bridged before + // the unpin landed is still waiting on its disk read or serving the phone, and either way it must + // go. A consent record that outlived its browse socket (#819) is withdrawn too. + function revokePeer(peerKeyB64: string): number { + if (!peerKeyB64) return 0 + approvals.forget(peerKeyB64) + let closed = 0 + let failure: unknown = null + for (const p of [...pool]) { + if (p.session.peerPublicKeyB64() !== peerKeyB64 && p.approvalPub !== peerKeyB64) continue + closed++ + try { + removeFromPool(p) // leaves the pool before the close, so a throwing close strands nothing + } catch (err) { + failure ??= err // keep cutting the phone's other sessions; report the failure after + } + } + if (closed) ensurePool() + if (failure) throw failure + return closed + } + /** Keep the pool topped up with TARGET_PENDING un-bridged listeners. */ function ensurePool(): void { if (running && pendingCount() < TARGET_PENDING) void connectOne() @@ -353,6 +455,8 @@ export function initStandingHost( // A phone completed the E2EE handshake on `pooled`'s listener. Mark it bridged (→ open a // replacement listener), then approve: pinned device → silent; unknown → prompt the human. + // Settles once that is decided (approved, handed to the human, refused, or torn down), which is + // when connectHostSession answers the requests the phone sent meanwhile. async function onPeerReady(pooled: Pooled): Promise { if (!pooled.bridged) { pooled.bridged = true @@ -375,9 +479,39 @@ export function initStandingHost( s.approve() // pinned device → auto-approve silently return } + if (!pub || !s.sas()) return // never offer consent without a verified handshake identity + // A paired phone whose key its pairing recorded but did not pin (remote access was off at the + // scan and the phone adopted the relay later over SSH, or the pin at the scan failed): the scan + // was the approval, so pin it now instead of asking a desk that is usually empty (A07-late). + // The check runs inside the pin store's queue, so a revoke racing it cannot be undone by it. + if (options.pinPairedPhone) { + const pinned = await options.pinPairedPhone(pub).catch((err) => { + console.warn('[standing-host] could not check the paired phone keys:', err) + return false + }) + if (!pool.has(pooled)) return // torn down (revoked, stopped) while that was in flight + if (pinned) { + s.approve() + return + } + } + // The user forgot this phone during this run, and this is it redialing (its session was cut by + // the revoke). No dialog for it: leave it unapproved, so it hears "Awaiting host approval.", and + // then close it as a Deny would, which the phone reads as a refusal (review of A07-revoke). + if (revokedThisRun.has(pub)) { + console.info('[standing-host] refused a phone revoked during this run') + clearDenyTimer(pooled) + pooled.denyTimer = setTimeout(() => { + pooled.denyTimer = null + if (!pool.has(pooled)) return + removeFromPool(pooled) + ensurePool() + }, REVOKED_PHONE_DENY_MS) + pooled.denyTimer.unref?.() + return + } // Keep the handshake-bound consent record after a browse socket closes (#819). The // human may still compare its SAS and pin this exact identity until the bounded deadline. - if (!pub || !s.sas()) return // never offer consent without a verified handshake identity pooled.approvalPub = pub pooled.approvalId = approvals.add(pub) send(IPC.remoteHostPeerPending, { @@ -442,9 +576,11 @@ export function initStandingHost( presence: createPhonePresence(), approvalPub: null, approvalId: null, - refreshTimer: null + refreshTimer: null, + denyTimer: null } pooled.session = connectHostSession({ + legacyRelayPairings: options.legacyRelayPairings, url: RELAY_URL, token: token.pairingToken, ourKeys: keys, @@ -459,11 +595,21 @@ export function initStandingHost( remoteViewer: bridge.remoteViewer, nodeActions: bridge.nodeActions, kanban: bridge.kanban, + inbox: bridge.inbox, + remoteNodes: bridge.remoteNodes, + newSessions: bridge.newSessions, chat: bridge.chat, extraRoots: bridge.workspaceRoots, + lanReport: bridge.lanReport, // Typing attribution: this pooled session's input frames are ITS phone's keystrokes. getClientId: () => pooled.presence.id(), - onPeerReady: () => void onPeerReady(pooled), + // Returned, not voided: connectHostSession holds the phone's requests until this settles, + // so a phone this host approves without the human is never told it is awaiting approval + // while the disk reads and the late pin are still running (review of A07-late). + onPeerReady: () => + onPeerReady(pooled).catch((err) => { + console.warn('[standing-host] the approval decision failed:', err) + }), onClose: () => { console.info('[phone-approval] socket-closed', { pending: !!pooled.approvalId }) pooled.presence.leave() @@ -471,6 +617,7 @@ export function initStandingHost( clearTimeout(pooled.refreshTimer) pooled.refreshTimer = null } + clearDenyTimer(pooled) pool.delete(pooled) // A session a phone was using ended: its replacement listener was already opened in // onPeerReady, so topping up is normally a no-op. An IDLE listener dropping on its own is @@ -508,21 +655,29 @@ export function initStandingHost( // (#819 keeps those alive for the SAS deadline). Otherwise a phone "Remove" would leave the removed // phone in its shell until the socket dropped, or let an open dialog re-pin it afterwards. registerPeerSessionKiller('phone', (match) => { - approvals.clearWhere(match) - let cut = false - for (const p of [...pool]) { - const key = p.session.peerPublicKeyB64() - if (key && match(key)) { - removeFromPool(p) - cut = true - } + const keys = new Set() + approvals.clearWhere((key) => { + if (!match(key)) return false + keys.add(key) // consent may outlive its browse socket, but never the revoke + return true + }) + for (const p of pool) { + const key = p.session.peerPublicKeyB64() ?? p.approvalPub + if (key && match(key)) keys.add(key) + } + let failure: unknown = null + for (const key of keys) { + rememberRevokedStandingPhone(key) + try { revokePeer(key) } + catch (err) { failure ??= err } } - if (cut) ensurePool() + if (failure) throw failure }) function start(): void { if (running) return running = true + runningHosts.add(revokePeer) reconnectAttempt = 0 popRefusals = 0 ensurePool() @@ -530,6 +685,7 @@ export function initStandingHost( function stop(): void { running = false + runningHosts.delete(revokePeer) approvals.stop() if (reconnectTimer) { clearTimeout(reconnectTimer) diff --git a/src/main/ssh-actions.wiring.test.ts b/src/main/ssh-actions.wiring.test.ts new file mode 100644 index 0000000000..999004aa6e --- /dev/null +++ b/src/main/ssh-actions.wiring.test.ts @@ -0,0 +1,20 @@ +// Native startup is an Electron boundary. Behavioral filesystem/store tests live in core; +// these integration pins prevent shipping the service unused or leaving its instance alive on quit. +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import path from 'node:path' +const source = readFileSync(path.join(__dirname, 'index.ts'), 'utf8') +describe('desktop SSH action service lifecycle', () => { + it('loads persisted node ownership before advertising the selected userData service', () => { + const start = source.indexOf('sshActionsService = await startSshActionsService(') + expect(start).toBeGreaterThan(0) + expect(source.slice(source.lastIndexOf('await workspaceStore.load(', start), start)).toContain('sideline: false') + const call = source.slice(start, source.indexOf(')', start) + 1) + expect(call).toMatch(/corePlatform\.userDataDir,\s*workspaceStore,\s*hostBridge\.nodeActions,\s*true,\s*managedTerminals/) + }) + it('retires queued file-service writes after confirmed quit and before PTY/master teardown', () => { + const quit = source.slice(source.indexOf("app.on('before-quit', (e) =>")) + expect(quit.indexOf('void sshActionsService?.stop()')).toBeGreaterThan(quit.indexOf('return\n }')) + expect(quit.indexOf('void sshActionsService?.stop()')).toBeLessThan(quit.indexOf('projectSetupService.disposeAll()')) + }) +}) diff --git a/src/main/ssh-host-keys.test.ts b/src/main/ssh-host-keys.test.ts new file mode 100644 index 0000000000..0d9bd0b962 --- /dev/null +++ b/src/main/ssh-host-keys.test.ts @@ -0,0 +1,454 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { closeSync, constants, mkdirSync, mkdtempSync, openSync, promises as fs, rmSync, symlinkSync, writeFileSync } from 'fs' +import { execFileSync } from 'child_process' +import { randomBytes } from 'crypto' +import os from 'os' +import path from 'path' +import { + MAX_SSH_HOST_KEYS, + SSH_HOST_KEY_DISCOVERY_LIMITS, + hostKeyPathsInSshdConfig, + includePathsInSshdConfig, + readSshHostKeyFingerprints, + sshFingerprintOfPublicKeyLine +} from './ssh-host-keys' + +// GitHub publishes its SSH host keys together with the fingerprints OpenSSH prints for them +// (docs.github.com, "GitHub's SSH key fingerprints"): an outside vector for the exact format, which is +// also the one sshj reports on the phone (`SshHostConnection.fingerprint`, checked against the same +// keys in the protocol tests). +const GITHUB_ED25519 = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' +const GITHUB_ED25519_FP = 'SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU' +const GITHUB_ECDSA = + 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=' +const GITHUB_ECDSA_FP = 'SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM' + +/** A fresh ed25519 public key line, as `ssh-keygen` writes a `.pub`. */ +function ed25519Line(comment = 'root@host'): string { + const name = Buffer.from('ssh-ed25519', 'ascii') + const len = (n: number): Buffer => { + const b = Buffer.alloc(4) + b.writeUInt32BE(n, 0) + return b + } + return `ssh-ed25519 ${Buffer.concat([len(name.length), name, len(32), randomBytes(32)]).toString('base64')} ${comment}` +} + +const dirs: string[] = [] +const tempDir = (): string => { + const d = mkdtempSync(path.join(os.tmpdir(), 'nt-ssh-host-keys-')) + dirs.push(d) + return d +} +afterEach(() => { + vi.restoreAllMocks() + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) +}) + +describe('Include host-key discovery', () => { + const publicKey = (directory: string, name: string): [string, string] => { + const key = path.join(directory, name) + const line = ed25519Line() + writeFileSync(key + '.pub', line + '\n') + return [key, sshFingerprintOfPublicKeyLine(line)!] + } + + it('parses quoted and multiple Include paths without interpreting comments or malformed quotes', () => { + expect(includePathsInSshdConfig([ + '# Include /ignored', + ' Include "/opt/ssh config/*.conf" /opt/other/?.conf # comment', + 'INCLUDE=relative/[ab].conf', + 'Include "/opt/with#hash.conf"', + 'Include "/unterminated /ignored', + 'Include /token/%u.conf', + 'HostKey /not-an-include' + ].join('\n'))).toEqual(['/opt/ssh config/*.conf', '/opt/other/?.conf', 'relative/[ab].conf', '/opt/with#hash.conf']) + }) + + it('follows external, nested and relative Includes in lexical glob order under the config root', async () => { + const root = tempDir() + const external = path.join(tempDir(), 'with space') + mkdirSync(external) + const keyDir = tempDir() + const [first, firstFp] = publicKey(keyDir, 'first') + const [second, secondFp] = publicKey(keyDir, 'second') + const [nested, nestedFp] = publicKey(keyDir, 'nested') + const [wrongBase, wrongFp] = publicKey(keyDir, 'wrong-base') + mkdirSync(path.join(root, 'nested')) + mkdirSync(path.join(external, 'nested')) + writeFileSync(path.join(root, 'sshd_config'), `Include "${external}/*.conf" missing/*.conf\n`) + writeFileSync(path.join(external, '20-key.conf'), `HostKey ${second}\n`) + writeFileSync(path.join(external, '10-key.conf'), `HostKey ${first}\nInclude nested/key-?.[c]onf\n`) + writeFileSync(path.join(root, 'nested', 'key-a.conf'), `HostKey ${nested}\nHostKey relative-key\n`) + writeFileSync(path.join(external, 'nested', 'key-a.conf'), `HostKey ${wrongBase}\n`) + const found = await readSshHostKeyFingerprints([root]) + expect(found).toEqual([firstFp, nestedFp, secondFp]) + expect(found).not.toContain(wrongFp) + }) + + it('deduplicates canonical files across symlink cycles while retaining ordinary drop-in supersets', async () => { + const root = tempDir() + const external = tempDir() + const [key, fp] = publicKey(external, 'custom') + const config = path.join(root, 'sshd_config') + const alias = path.join(external, 'cycle.conf') + symlinkSync(config, alias) + writeFileSync(config, `Include ${external}/*.conf\n`) + writeFileSync(path.join(external, 'key.conf'), `HostKey ${key}\nInclude ${alias}\n`) + mkdirSync(path.join(root, 'sshd_config.d')) + writeFileSync(path.join(root, 'sshd_config.d', 'without-suffix'), `HostKey ${key}\n`) + const opened: string[] = [] + const original = fs.open.bind(fs) + vi.spyOn(fs, 'open').mockImplementation(async (...args: Parameters) => { + opened.push(String(args[0])) + return original(...args) + }) + expect(await readSshHostKeyFingerprints([root])).toEqual([fp]) + expect(opened.filter((name) => name === config)).toHaveLength(1) + expect(opened).not.toContain(alias) + }) + + it('does not open private HostKeys, even through Include globs or a public-name symlink', async () => { + const root = tempDir() + const external = tempDir() + const [custom, fp] = publicKey(external, 'custom-secret') + const generatedPrivate = path.join(external, 'ssh_host_ed25519_key') + writeFileSync(custom, 'PRIVATE HOSTKEY SENTINEL\n') + writeFileSync(generatedPrivate, 'ANOTHER PRIVATE SENTINEL\n') + symlinkSync(custom, path.join(root, 'ssh_host_alias_key.pub')) + writeFileSync(path.join(root, 'sshd_config'), `HostKey ${custom}\nInclude ${external}/*\n`) + const opened: string[] = [] + const original = fs.open.bind(fs) + vi.spyOn(fs, 'open').mockImplementation(async (...args: Parameters) => { + const name = String(args[0]); opened.push(name) + if (name === custom || name === generatedPrivate) throw new Error('Private-key reader regression') + return original(...args) + }) + expect(await readSshHostKeyFingerprints([root])).toEqual([fp]) + expect(opened).not.toContain(custom) + expect(opened).not.toContain(generatedPrivate) + }) + + it('excludes declared relative private names from lexical and canonical Include targets without anchoring them', async () => { + const root = tempDir() + const external = tempDir() + const lexical = tempDir() + const keys = tempDir() + const [valid, fp] = publicKey(keys, 'valid') + const [decoy] = publicKey(keys, 'decoy') + const custom = path.join(external, 'custom-host') + const dotted = path.join(external, 'normalized-host') + const lexicalTarget = path.join(keys, 'lexical-target.conf') + // These are synthetic sentinels, never real host keys. Their declaration does not establish + // sshd's launch directory, so matching names must be excluded rather than resolved as anchors. + writeFileSync(custom, 'SYNTHETIC PRIVATE HOSTKEY SENTINEL\n') + writeFileSync(dotted, 'SYNTHETIC DOT/PARENT HOSTKEY SENTINEL\n') + writeFileSync(lexicalTarget, `HostKey ${decoy}\n`) + symlinkSync(custom, path.join(external, '00-alias.conf')) + // A lexical name match is excluded even when its current symlink resolves somewhere else. + symlinkSync(lexicalTarget, path.join(lexical, 'custom-host')) + writeFileSync(path.join(root, 'sshd_config'), [ + 'HOSTKEY="keys/../custom-host"', + 'HostKey ./parent/../normalized-host', + `HostKey ${valid}`, + `Include ${external}/* ${lexical}/*` + ].join('\n')) + const opened: string[] = [] + const original = fs.open.bind(fs) + vi.spyOn(fs, 'open').mockImplementation(async (...args: Parameters) => { + opened.push(String(args[0])) + return original(...args) + }) + const found = await readSshHostKeyFingerprints([root]) + expect(opened).not.toContain(custom) + expect(opened).not.toContain(dotted) + expect(opened).not.toContain(lexicalTarget) + expect(found).toEqual([fp]) + }) + + it('keeps custom public declarations readable while relative public names remain unanchored', async () => { + const root = tempDir() + const external = tempDir() + const keys = tempDir() + const [relative, relativeFp] = publicKey(external, 'custom-public') + const [absolute, absoluteFp] = publicKey(keys, 'agent-held') + const [valid, validFp] = publicKey(keys, 'valid') + const relativePublic = relative + '.pub' + // Neither the config root nor a matching Include establishes the daemon's HostKey base. + publicKey(root, 'custom-public') + writeFileSync(path.join(root, 'sshd_config'), [ + 'HostKey keys/../custom-public.pub', + `HostKey ${absolute}.pub`, + `HostKey ${valid}`, + `Include ${external}/*.pub` + ].join('\n')) + const opened: string[] = [] + const original = fs.open.bind(fs) + vi.spyOn(fs, 'open').mockImplementation(async (...args: Parameters) => { + opened.push(String(args[0])) + return original(...args) + }) + const found = await readSshHostKeyFingerprints([root]) + expect(found).toEqual([absoluteFp, validFp]) + expect(found).not.toContain(relativeFp) + expect(opened).toContain(relativePublic) + }) + + it('bounds relative HostKey declarations before visiting any more Includes', async () => { + const root = tempDir() + const external = tempDir() + const [valid, fp] = publicKey(tempDir(), 'valid') + const [late] = publicKey(tempDir(), 'late') + const included = path.join(external, 'after-overflow.conf') + writeFileSync(included, `HostKey ${late}\n`) + const relative = Array.from({ length: SSH_HOST_KEY_DISCOVERY_LIMITS.publicFiles }, (_, i) => `HostKey keys/relative-${i}\n`).join('') + writeFileSync(path.join(root, 'sshd_config'), `HostKey ${valid}\n${relative}Include ${included}\n`) + const opened: string[] = [] + const original = fs.open.bind(fs) + vi.spyOn(fs, 'open').mockImplementation(async (...args: Parameters) => { + opened.push(String(args[0])) + return original(...args) + }) + expect(await readSshHostKeyFingerprints([root])).toEqual([fp]) + expect(opened).not.toContain(included) + }) + + it.skipIf(process.platform === 'win32')('never blocks if a config or public file becomes a FIFO between stat and open', async () => { + const root = tempDir() + const external = tempDir() + const [key, fp] = publicKey(external, 'valid') + const pipes = [path.join(root, 'ssh_host_fifo_key.pub'), path.join(external, 'blocked.conf')] + execFileSync('mkfifo', pipes) + writeFileSync(path.join(root, 'sshd_config'), `HostKey ${key}\nInclude ${pipes[1]}\n`) + const original = fs.stat.bind(fs) + vi.spyOn(fs, 'stat').mockImplementation(async (...args: Parameters) => { + const result = await original(...args) + // Model a regular file that is replaced after the path stat. FileHandle.stat still sees + // the real FIFO, and O_NONBLOCK must let it reach that check without waiting for a writer. + if (result && pipes.includes(String(args[0]))) { + result.isFile = () => true + result.size = typeof result.size === 'bigint' ? 0n : 0 + } + return result + }) + const work = readSshHostKeyFingerprints([root]) + let timeout: ReturnType | undefined + try { + await expect(Promise.race([work, new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error('FIFO discovery exceeded its bounded wait')), 500) + })])).resolves.toEqual([fp]) + } finally { + clearTimeout(timeout) + // A broken nonblocking mutant still has only these disposable FIFOs. Supply writers long + // enough to release both opens, then await its cleanup so no test thread is left blocked. + const writers = pipes.map((pipe) => openSync(pipe, constants.O_RDWR | constants.O_NONBLOCK)) + try { await work } finally { writers.forEach(closeSync) } + } + }) + + it('skips oversized, unreadable, malformed and nonmatching Includes without losing readable keys', async () => { + const root = tempDir() + const external = tempDir() + const [valid, fp] = publicKey(external, 'valid') + const [tooLargeKey] = publicKey(external, 'too-large') + const oversized = path.join(external, '00-large.conf') + const denied = path.join(external, '10-denied.conf') + writeFileSync(oversized, `HostKey ${tooLargeKey}\n` + ' '.repeat(256 * 1024)) + writeFileSync(denied, `HostKey ${valid}\n`) + writeFileSync(path.join(external, '20-valid.conf'), `HostKey ${valid}\n`) + mkdirSync(path.join(external, '30-directory.conf')) + writeFileSync(path.join(root, 'sshd_config'), `Include ${external}/*.conf ${external}/none*.conf ${external}/[z-a].conf\n`) + const original = fs.open.bind(fs) + vi.spyOn(fs, 'open').mockImplementation(async (...args: Parameters) => { + if (String(args[0]) === denied) throw Object.assign(new Error('denied'), { code: 'EACCES' }) + return original(...args) + }) + expect(await readSshHostKeyFingerprints([root])).toEqual([fp]) + }) + + it('stops a deeply nested Include chain before its late key', async () => { + const root = tempDir() + const external = tempDir() + const [near, nearFp] = publicKey(external, 'near') + const [late] = publicKey(external, 'late') + writeFileSync(path.join(root, 'sshd_config'), `HostKey ${near}\nInclude ${external}/0.conf\n`) + for (let i = 0; i <= SSH_HOST_KEY_DISCOVERY_LIMITS.depth; i++) { + writeFileSync(path.join(external, `${i}.conf`), i === SSH_HOST_KEY_DISCOVERY_LIMITS.depth + ? `HostKey ${late}\n` : `Include ${external}/${i + 1}.conf\n`) + } + expect(await readSshHostKeyFingerprints([root])).toEqual([nearFp]) + }) + + it('bounds config attempts, including a wide Include tree', async () => { + const root = tempDir() + const external = tempDir() + const [late] = publicKey(external, 'late') + writeFileSync(path.join(root, 'sshd_config'), `Include ${external}/*.conf\n`) + for (let i = 0; i < SSH_HOST_KEY_DISCOVERY_LIMITS.configFiles; i++) { + writeFileSync(path.join(external, `${String(i).padStart(4, '0')}.conf`), i === SSH_HOST_KEY_DISCOVERY_LIMITS.configFiles - 1 ? `HostKey ${late}\n` : '# empty\n') + } + expect(await readSshHostKeyFingerprints([root])).toEqual([]) + }) + + it('bounds total config bytes independently of each individual file limit', async () => { + const root = tempDir() + const external = tempDir() + const [late] = publicKey(external, 'late') + const config = `Include ${external}/*.conf\n` + writeFileSync(path.join(root, 'sshd_config'), config) + for (let i = 0; i < 4; i++) { + const bytes = 256 * 1024 - (i === 3 ? Buffer.byteLength(config) : 0) + writeFileSync(path.join(external, `${i}.conf`), ' '.repeat(bytes)) + } + writeFileSync(path.join(external, 'late.conf'), `HostKey ${late}\n`) + expect(await readSshHostKeyFingerprints([root])).toEqual([]) + }) + + it('bounds the directory scan rather than walking a huge Include tree', async () => { + const root = tempDir() + const external = tempDir() + const [late] = publicKey(external, 'late') + writeFileSync(path.join(root, 'sshd_config'), `Include ${external}/*.conf\n`) + for (let i = 0; i < SSH_HOST_KEY_DISCOVERY_LIMITS.directoryEntries; i++) writeFileSync(path.join(external, `${i}.conf`), i === 0 ? `HostKey ${late}\n` : '# empty\n') + writeFileSync(path.join(external, 'late.conf'), `HostKey ${late}\n`) + expect(await readSshHostKeyFingerprints([root])).toEqual([]) + }) + + it('bounds invalid public-key candidates before the final unique-anchor cap', async () => { + const root = tempDir() + for (let i = 0; i < SSH_HOST_KEY_DISCOVERY_LIMITS.publicFiles; i++) writeFileSync(path.join(root, `ssh_host_${String(i).padStart(4, '0')}_key.pub`), 'invalid\n') + writeFileSync(path.join(root, 'ssh_host_z_key.pub'), ed25519Line() + '\n') + expect(await readSshHostKeyFingerprints([root])).toEqual([]) + }) + + it('deduplicates and bounds HostKey canonicalization before following more Includes', async () => { + const root = tempDir() + const external = tempDir() + const [valid, fp] = publicKey(external, 'valid') + const config = path.join(root, 'sshd_config') + const canonicalized: string[] = [] + const original = fs.realpath.bind(fs) + vi.spyOn(fs, 'realpath').mockImplementation(async (...args: Parameters) => { + canonicalized.push(String(args[0])) + return original(...args) + }) + const repeatedInclude = path.join(external, 'repeated.conf') + writeFileSync(repeatedInclude, `HostKey ${valid}\n`.repeat(1000)) + writeFileSync(config, `HostKey ${valid}\n`.repeat(1000) + `Include ${repeatedInclude}\n`) + expect(await readSshHostKeyFingerprints([root])).toEqual([fp]) + expect(canonicalized.filter((name) => name === valid)).toHaveLength(1) + + canonicalized.length = 0 + const paths = Array.from({ length: SSH_HOST_KEY_DISCOVERY_LIMITS.publicFiles }, (_, i) => path.join(external, `key-${i}`)) + const included = path.join(external, 'after-overflow.conf') + writeFileSync(included, `HostKey ${valid}\n`) + writeFileSync(config, paths.map((key) => `HostKey ${key}\n`).join('') + `HostKey ${valid}\nInclude ${included}\n`) + expect(await readSshHostKeyFingerprints([root])).toEqual([]) + expect(canonicalized.filter((name) => paths.includes(name))).toHaveLength(SSH_HOST_KEY_DISCOVERY_LIMITS.publicFiles) + expect(canonicalized).not.toContain(valid) + expect(canonicalized).not.toContain(included) + }) +}) + +describe('sshFingerprintOfPublicKeyLine', () => { + it('prints what OpenSSH prints: SHA256 of the key blob, unpadded base64', () => { + expect(sshFingerprintOfPublicKeyLine(`${GITHUB_ED25519} root@github\n`)).toBe(GITHUB_ED25519_FP) + expect(sshFingerprintOfPublicKeyLine(GITHUB_ECDSA)).toBe(GITHUB_ECDSA_FP) + // A comment line and a blank line before the key, and CRLF, change nothing. + expect(sshFingerprintOfPublicKeyLine(`# host key\r\n\r\n${GITHUB_ED25519}\r\n`)).toBe(GITHUB_ED25519_FP) + }) + + it('refuses what is not a plain public key', () => { + const [, blob] = GITHUB_ED25519.split(' ') + // The line says one type and the blob another: it is not the key it claims to be. + expect(sshFingerprintOfPublicKeyLine(`ssh-rsa ${blob}`)).toBeNull() + expect(sshFingerprintOfPublicKeyLine(`ssh-ed25519-cert-v01@openssh.com ${blob}`)).toBeNull() + expect(sshFingerprintOfPublicKeyLine('ssh-ed25519 not*base64')).toBeNull() + expect(sshFingerprintOfPublicKeyLine('ssh-ed25519')).toBeNull() + expect(sshFingerprintOfPublicKeyLine('ssh-ed25519 AAAA')).toBeNull() + expect(sshFingerprintOfPublicKeyLine('')).toBeNull() + }) +}) + +describe('hostKeyPathsInSshdConfig', () => { + it('reads every absolute HostKey, in both keyword forms and quoted', () => { + const text = [ + '# HostKey /etc/ssh/commented_out', + 'HostKey /etc/ssh/ssh_host_ed25519_key', + ' hostkey=/opt/keys/a_key', + 'HOSTKEY "/opt/keys/with space_key"', + 'HostKey relative_key', + 'HostKey /home/%u/token_key', + 'HostKeyAlgorithms ssh-ed25519', + 'Port 22' + ].join('\n') + expect(hostKeyPathsInSshdConfig(text)).toEqual([ + '/etc/ssh/ssh_host_ed25519_key', + '/opt/keys/a_key', + '/opt/keys/with space_key' + ]) + }) +}) + +describe('readSshHostKeyFingerprints', () => { + it('reads every ssh_host_*_key.pub in the directories, and nothing else there', async () => { + const etcSsh = tempDir() + const etc = tempDir() + writeFileSync(path.join(etcSsh, 'ssh_host_ed25519_key.pub'), `${GITHUB_ED25519} root@box\n`) + writeFileSync(path.join(etcSsh, 'ssh_host_ecdsa_key.pub'), `${GITHUB_ECDSA} root@box\n`) + // A certificate, a private key, a user key and a key under another name are not host public keys. + writeFileSync(path.join(etcSsh, 'ssh_host_ed25519_key-cert.pub'), `${ed25519Line()}\n`) + writeFileSync(path.join(etcSsh, 'ssh_host_ed25519_key'), '-----BEGIN OPENSSH PRIVATE KEY-----\n') + writeFileSync(path.join(etcSsh, 'id_ed25519.pub'), `${ed25519Line()}\n`) + // The old macOS place (/etc), the same key again: listed once. + writeFileSync(path.join(etc, 'ssh_host_ed25519_key.pub'), `${GITHUB_ED25519} root@old-mac\n`) + expect(await readSshHostKeyFingerprints([etcSsh, etc])).toEqual([GITHUB_ECDSA_FP, GITHUB_ED25519_FP]) + }) + + it('adds the keys sshd_config and its drop-ins name elsewhere', async () => { + const etcSsh = tempDir() + const keys = tempDir() + const own = ed25519Line() + const agentHeld = ed25519Line() + const unsuffixed = ed25519Line() + writeFileSync(path.join(keys, 'custom_key.pub'), `${own}\n`) + writeFileSync(path.join(keys, 'agent_key.pub'), `${agentHeld}\n`) + writeFileSync(path.join(keys, 'other_key.pub'), `${unsuffixed}\n`) + writeFileSync(path.join(etcSsh, 'sshd_config'), `HostKey ${path.join(keys, 'custom_key')}\n`) + mkdirSync(path.join(etcSsh, 'sshd_config.d')) + // A public key named directly (sshd takes one when the private key is held by an agent). + writeFileSync(path.join(etcSsh, 'sshd_config.d', '10-agent.conf'), `HostKey ${path.join(keys, 'agent_key.pub')}\n`) + // A drop-in without the `.conf` suffix: sshd reads it when its Include globs the whole directory, + // and a key it serves from there must be named, or the phone refuses SSH (review of A49-anchor). + writeFileSync(path.join(etcSsh, 'sshd_config.d', '20-keys'), `HostKey ${path.join(keys, 'other_key')}\n`) + // A drop-in naming a key that is not there, and a directory among them: nothing, and no failure. + writeFileSync(path.join(etcSsh, 'sshd_config.d', '30-missing.conf'), `HostKey ${path.join(keys, 'nope')}\n`) + mkdirSync(path.join(etcSsh, 'sshd_config.d', '40-dir')) + expect(await readSshHostKeyFingerprints([etcSsh])).toEqual([ + sshFingerprintOfPublicKeyLine(own), + sshFingerprintOfPublicKeyLine(agentHeld), + sshFingerprintOfPublicKeyLine(unsuffixed) + ]) + }) + + it('leaves out what cannot be read, and gives an empty list when nothing can', async () => { + const etcSsh = tempDir() + // Not a regular file, too large to be a key, and not a key at all. + mkdirSync(path.join(etcSsh, 'ssh_host_dir_key.pub')) + writeFileSync(path.join(etcSsh, 'ssh_host_big_key.pub'), `${GITHUB_ED25519} ${'x'.repeat(20_000)}\n`) + writeFileSync(path.join(etcSsh, 'ssh_host_junk_key.pub'), 'not a key\n') + writeFileSync(path.join(etcSsh, 'sshd_config'), 'HostKey /nonexistent/nt-test/missing_key\n') + expect(await readSshHostKeyFingerprints([etcSsh, path.join(etcSsh, 'no-such-dir')])).toEqual([]) + // …and a readable key beside them is still read. + writeFileSync(path.join(etcSsh, 'ssh_host_ok_key.pub'), `${GITHUB_ED25519}\n`) + expect(await readSshHostKeyFingerprints([etcSsh])).toEqual([GITHUB_ED25519_FP]) + }) + + it('stops at the cap', async () => { + const etcSsh = tempDir() + for (let i = 0; i < MAX_SSH_HOST_KEYS + 4; i++) { + writeFileSync(path.join(etcSsh, `ssh_host_k${String(i).padStart(2, '0')}_key.pub`), `${ed25519Line()}\n`) + } + expect(await readSshHostKeyFingerprints([etcSsh])).toHaveLength(MAX_SSH_HOST_KEYS) + }) +}) diff --git a/src/main/ssh-host-keys.ts b/src/main/ssh-host-keys.ts new file mode 100644 index 0000000000..e711ec16d2 --- /dev/null +++ b/src/main/ssh-host-keys.ts @@ -0,0 +1,345 @@ +// This computer's own SSH host keys, as the fingerprints a paired phone checks its first SSH connect +// against (audit A49-anchor). +// +// Without them the phone's first connect is trust on first use: it pins whichever key answers at the +// paired address, once that server has accepted the phone's key. The pairing already crosses a channel +// that proves which computer is on the other end (the `/pair` body is sealed to the host key the QR on +// this screen carries), so the desktop hands the phone its host key fingerprints inside that sealed +// answer, and the first connect must present one of them. +// +// Read-only and never fatal: an unreadable directory, file or config yields nothing for it, and a +// pairing whose answer carries no fingerprints leaves the phone on its trust-on-first-use pin, exactly +// as before. The fingerprint is OpenSSH's (`ssh-keygen -l -E sha256`), which is also what sshj reports +// on the phone: `SHA256:` + the unpadded standard base64 of the SHA-256 of the key blob. + +import { createHash } from 'crypto' +import { constants, promises as fs } from 'fs' +import path from 'path' + +/** + * Where sshd keeps its host keys and its config. `/etc/ssh` on Linux and on macOS 10.11 and later; + * macOS 10.10 and older kept `ssh_host_*_key` and `sshd_config` directly in `/etc` (`/private/etc`). + */ +export const SSH_HOST_KEY_DIRS: readonly string[] = ['/etc/ssh', '/etc'] + +/** The most fingerprints a pairing answer carries. sshd serves three or four; the phone keeps as many. */ +export const MAX_SSH_HOST_KEYS = 16 + +/** A `.pub` larger than this is not a host public key (an RSA-16384 line is under 3 KiB). */ +const MAX_PUB_BYTES = 16 * 1024 +/** sshd_config files larger than this are not read. */ +const MAX_CONFIG_BYTES = 256 * 1024 + +/** Discovery is a best-effort read of administrator config, never an unbounded directory walk. */ +export const SSH_HOST_KEY_DISCOVERY_LIMITS = { + depth: 16, + configFiles: 128, + totalConfigBytes: 1024 * 1024, + directoryEntries: 4096, + includeMatches: 256, + includePatternBytes: 4096, + publicFiles: 256 +} as const + +/** The default host key names sshd generates: `ssh_host_ed25519_key.pub` and the like. */ +const HOST_KEY_PUB = /^ssh_host_[A-Za-z0-9_]+_key\.pub$/ +/** An SSH public key algorithm name (`ssh-ed25519`, `ecdsa-sha2-nistp256`, `sk-ssh-ed25519@openssh.com`). */ +const KEY_TYPE = /^[a-z0-9][a-z0-9.@-]*$/ +const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/ + +/** + * OpenSSH's `SHA256:` fingerprint of the first key line in [text] (a `.pub` file), or + * null when that line is not a plain public key: a certificate, a blob that does not name its own + * type, or anything that is not base64. Comment and blank lines before it are skipped. + */ +export function sshFingerprintOfPublicKeyLine(text: string): string | null { + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim() + if (!line || line.startsWith('#')) continue + const [type, b64] = line.split(/\s+/) + if (!type || !b64 || !KEY_TYPE.test(type) || type.includes('-cert-') || !BASE64.test(b64)) return null + const blob = Buffer.from(b64, 'base64') + // The blob opens with its own key type as an SSH string (RFC 4253 §6.6). One that does not is not + // the key its line names, and its fingerprint would match nothing a server presents. + if (blob.length < 4) return null + const n = blob.readUInt32BE(0) + if (n !== Buffer.byteLength(type) || blob.length <= 4 + n) return null + if (blob.subarray(4, 4 + n).toString('latin1') !== type) return null + return 'SHA256:' + createHash('sha256').update(blob).digest('base64').replace(/=+$/, '') + } + return null +} + +/** The fingerprint of one `.pub` file, or null when it cannot be read or is not a key. */ +async function fingerprintOfFile(file: string): Promise { + try { + // A public-name symlink must not turn this reader into a private-key reader. + const real = await fs.realpath(file) + if (!real.endsWith('.pub')) return null + const read = await readBoundedText(real, MAX_PUB_BYTES) + return read ? sshFingerprintOfPublicKeyLine(read.text) : null + } catch { + return null + } +} + +/** Bound the read itself as well as stat: a growing file cannot evade the byte limit. */ +async function readBoundedText(file: string, limit: number): Promise<{ text: string; bytes: number } | null> { + let handle: Awaited> | undefined + try { + const before = await fs.stat(file) + if (!before.isFile() || before.size > limit) return null + // The second stat checks the opened inode. Nonblocking/no-follow also protects against a + // concurrent regular-file replacement by a FIFO or symlink between the first stat and open. + handle = await fs.open(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0)) + const st = await handle.stat() + if (!st.isFile() || st.size > limit) return null + const buffer = Buffer.alloc(limit + 1) + let bytes = 0 + while (bytes < buffer.length) { + const read = await handle.read(buffer, bytes, buffer.length - bytes, bytes) + if (!read.bytesRead) break + bytes += read.bytesRead + } + return bytes <= limit ? { text: buffer.subarray(0, bytes).toString('utf8'), bytes } : null + } catch { + return null + } finally { + await handle?.close().catch(() => {}) + } +} + +/** + * The absolute `HostKey` paths an sshd config text names. sshd takes `Keyword value` or + * `Keyword=value`, keywords case-insensitively, and a value in double quotes. A relative or + * token-bearing path is skipped: there is no telling here what sshd would resolve it to. + */ +export function hostKeyPathsInSshdConfig(text: string): string[] { + return hostKeyValuesInSshdConfig(text).filter((value) => path.posix.isAbsolute(value)) +} + +/** Relative declarations are useful only for excluding private reads, never for guessing anchors. */ +function hostKeyValuesInSshdConfig(text: string): string[] { + const out: string[] = [] + for (const raw of text.split(/\r?\n/)) { + const m = /^\s*hostkey(?:\s*=\s*|\s+)(.*)$/i.exec(raw) + if (!m) continue + let value = m[1].trim() + const quoted = /^"([^"]*)"/.exec(value) + value = quoted ? quoted[1] : value.split(/\s+/)[0] + if (value && !value.includes('%') && !value.includes('\0')) out.push(value) + } + return out +} + +/** Include values, with quoted whitespace, multiple paths and comments outside quotes. */ +export function includePathsInSshdConfig(text: string): string[] { + const out: string[] = [] + for (const raw of text.split(/\r?\n/)) { + const directive = /^\s*include(?:\s*=\s*|\s+)(.*)$/i.exec(raw) + if (!directive) continue + const words: string[] = [] + let word = '' + let quoted = false + for (const char of directive[1]) { + if (char === '"') quoted = !quoted + else if (!quoted && char === '#') break + else if (!quoted && /\s/.test(char)) { + if (word) words.push(word) + word = '' + } else word += char + } + if (quoted) continue // An invalid directive must not produce guessed paths. + if (word) words.push(word) + out.push(...words.filter((value) => !value.includes('%') && !value.includes('\0'))) + } + return out +} + +type Discovery = { + files: number + bytes: number + entries: number + matches: number + visited: Set + privateKeys: Set + relativePrivateNames: Set + keyPaths: Set + keysExhausted: boolean +} + +/** POSIX glob components, without shell expansion, globstar recursion or command execution. */ +function globComponent(pattern: string): (name: string) => boolean { + const tokens: Array<'*' | '?' | RegExp | { literal: string }> = [] + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i] + if (char === '*' || char === '?') tokens.push(char) + else if (char === '[') { + const end = pattern.indexOf(']', i + (pattern[i + 1] === '!' ? 2 : 1)) + if (end < 0) tokens.push({ literal: '[' }) + else { + const members = pattern.slice(i + 1, end) + tokens.push(new RegExp('^[' + (members.startsWith('!') ? '^' + members.slice(1) : members).replace(/\\/g, '\\\\') + ']$')) + i = end + } + } else tokens.push({ literal: char }) + } + // Greedy wildcard matching with a single retry point avoids a backtracking regex over the + // administrator-supplied pattern. Character-class regexes consume exactly one character. + return (name) => { + let at = 0, token = 0, star = -1, retry = 0 + while (at < name.length) { + const next = tokens[token] + if (next === '?' || (next instanceof RegExp ? next.test(name[at]) : typeof next === 'object' && next.literal === name[at])) { + at++; token++ + } else if (next === '*') { + star = token++; retry = at + } else if (star >= 0) { + token = star + 1; at = ++retry + } else return false + } + while (tokens[token] === '*') token++ + return token === tokens.length + } +} + +/** A huge directory exhausts the scan budget instead of returning an arbitrary unsorted prefix. */ +async function boundedNames(dir: string, state: Discovery): Promise { + if (state.entries >= SSH_HOST_KEY_DISCOVERY_LIMITS.directoryEntries) return [] + const names: string[] = [] + try { + const handle = await fs.opendir(dir) + for await (const entry of handle) { + if (++state.entries > SSH_HOST_KEY_DISCOVERY_LIMITS.directoryEntries) return [] + names.push(entry.name) + } + } catch { + return [] + } + return names.sort() +} + +async function includeMatches(pattern: string, base: string, state: Discovery): Promise { + if (state.matches >= SSH_HOST_KEY_DISCOVERY_LIMITS.includeMatches || + Buffer.byteLength(pattern) > SSH_HOST_KEY_DISCOVERY_LIMITS.includePatternBytes) return [] + // OpenSSH resolves every relative Include under its configuration root (/etc/ssh), including + // directives inside another included file. The first injected dir models that root in tests. + const absolute = path.posix.isAbsolute(pattern) ? path.posix.normalize(pattern) : path.posix.resolve(base, pattern) + let candidates = ['/'] + try { + for (const component of absolute.split('/').filter(Boolean)) { + const next: string[] = [] + const glob = /[*?\[]/.test(component) ? globComponent(component) : null + for (const candidate of candidates) { + const names = glob ? await boundedNames(candidate, state) : [component] + for (const name of names) { + if (glob && ((!component.startsWith('.') && name.startsWith('.')) || !glob(name))) continue + next.push(path.posix.join(candidate, name)) + if (next.length >= SSH_HOST_KEY_DISCOVERY_LIMITS.includeMatches - state.matches) break + } + if (next.length >= SSH_HOST_KEY_DISCOVERY_LIMITS.includeMatches - state.matches) break + } + candidates = next + if (!candidates.length) break + } + } catch { + return [] // e.g. a malformed character range, an unreadable glob directory. + } + state.matches += candidates.length + return candidates +} + +const PRIVATE_KEY_NAME = /^(?:ssh_host_[A-Za-z0-9_]+_key|id_(?:rsa|dsa|ecdsa|ed25519))$|\.(?:key|pem|p12|pfx|pk8|jks|keystore)$/i + +/** + * The host key paths `/sshd_config`, its bounded Includes and every `sshd_config.d/` file configure. Every file, + * not only `*.conf`: which drop-ins sshd reads is its `Include` glob's call (`*.conf` on Debian and + * Fedora; a config may as well include the whole directory), and a key read from a drop-in sshd skips costs + * nothing (the list is a superset), while a key sshd serves from one this skipped makes the phone + * refuse SSH to its own computer, and pairing again would only read the same files (review of + * A49-anchor). + */ +async function configuredHostKeys(dir: string, includeBase: string, state: Discovery): Promise { + const files = [path.join(dir, 'sshd_config')] + const dropIns = path.join(dir, 'sshd_config.d') + for (const name of await boundedNames(dropIns, state)) files.push(path.join(dropIns, name)) + const out: string[] = [] + const visit = async (file: string, depth: number): Promise => { + if (depth > SSH_HOST_KEY_DISCOVERY_LIMITS.depth || state.files >= SSH_HOST_KEY_DISCOVERY_LIMITS.configFiles || + state.bytes >= SSH_HOST_KEY_DISCOVERY_LIMITS.totalConfigBytes || state.keysExhausted) return + if (PRIVATE_KEY_NAME.test(path.basename(file)) || state.privateKeys.has(path.resolve(file)) || + state.relativePrivateNames.has(path.basename(file))) return + state.files++ // failed/unreadable attempts count too. + const real = await fs.realpath(file).catch(() => null) + if (!real || state.visited.has(real) || PRIVATE_KEY_NAME.test(path.basename(real)) || state.privateKeys.has(real) || + state.relativePrivateNames.has(path.basename(real))) return + state.visited.add(real) + const read = await readBoundedText(real, Math.min(MAX_CONFIG_BYTES, SSH_HOST_KEY_DISCOVERY_LIMITS.totalConfigBytes - state.bytes)) + if (!read) return + state.bytes += read.bytes + const keys = [...new Set(hostKeyValuesInSshdConfig(read.text))] + // A glob must not read the private paths this config names, even if they have custom names. + for (const key of keys.filter((key) => path.posix.isAbsolute(key) && !key.endsWith('.pub'))) { + state.privateKeys.add(path.resolve(key)) + } + for (const key of keys) { + if (state.keyPaths.has(key)) continue + if (state.keyPaths.size >= SSH_HOST_KEY_DISCOVERY_LIMITS.publicFiles) { + // Stop Include traversal too: an overflow key alias has not been canonicalized, so it + // cannot safely be ruled out as a private-key target of a later Include. + state.keysExhausted = true + break + } + state.keyPaths.add(key) + if (!path.posix.isAbsolute(key)) { + // The daemon's working directory is unknown. A same-named Include could be its private + // key, including through a symlink, so exclude lexical and canonical basenames instead + // of resolving the declaration under our config root. Explicit .pub names stay public. + const normalized = path.posix.normalize(key) + if (!normalized.endsWith('.pub')) state.relativePrivateNames.add(path.posix.basename(normalized)) + continue + } + out.push(key) + if (key.endsWith('.pub')) continue + const realKey = await fs.realpath(key).catch(() => null) + if (realKey) state.privateKeys.add(realKey) + } + if (state.keysExhausted) return + for (const pattern of includePathsInSshdConfig(read.text)) { + for (const included of await includeMatches(pattern, includeBase, state)) await visit(included, depth + 1) + } + } + for (const file of files) await visit(file, 0) + return out +} + +/** + * The fingerprints of this computer's SSH host keys: every `ssh_host_*_key.pub` in [dirs], then the + * `.pub` beside every key a `HostKey` line in `/sshd_config`, an included config or `sshd_config.d/` + * names (or that file itself when it is already a `.pub`, which sshd accepts when the private + * key lives in an agent), in that order, without duplicates and at most [MAX_SSH_HOST_KEYS]. sshd serves one + * of its keys per connection, so the list is a superset rather than a guess: a key here that sshd + * does not serve costs nothing, a key sshd serves that is missing here makes the phone refuse SSH. + * The first directory models OpenSSH's `/etc/ssh` root for relative Includes. Discovery budgets + * apply across all directories. Never throws; unreadable/over-budget inputs are left out. + */ +export async function readSshHostKeyFingerprints(dirs: readonly string[] = SSH_HOST_KEY_DIRS): Promise { + const files: string[] = [] + const state: Discovery = { + files: 0, bytes: 0, entries: 0, matches: 0, visited: new Set(), privateKeys: new Set(), relativePrivateNames: new Set(), + keyPaths: new Set(), keysExhausted: false + } + const includeBase = dirs[0] ?? SSH_HOST_KEY_DIRS[0] + for (const dir of dirs) { + const names = await boundedNames(dir, state) + for (const name of names.filter((n) => HOST_KEY_PUB.test(n)).sort()) files.push(path.join(dir, name)) + for (const key of await configuredHostKeys(dir, includeBase, state)) files.push(key.endsWith('.pub') ? key : `${key}.pub`) + } + const out: string[] = [] + for (const file of [...new Set(files)].slice(0, SSH_HOST_KEY_DISCOVERY_LIMITS.publicFiles)) { + if (out.length >= MAX_SSH_HOST_KEYS) break + const fp = await fingerprintOfFile(file) + if (fp && !out.includes(fp)) out.push(fp) + } + return out +} diff --git a/src/preload/index.test.ts b/src/preload/index.test.ts index 795da90ac1..50daf78446 100644 --- a/src/preload/index.test.ts +++ b/src/preload/index.test.ts @@ -5,15 +5,20 @@ // the exact invoke/on wiring the real contextBridge would expose. import { describe, expect, it, vi } from 'vitest' import { IPC } from '../shared/ipc' -import type { NodeTerminalApi, SshPassphraseRequest } from '../shared/types' - -const h = vi.hoisted(() => ({ - invoke: vi.fn(async () => undefined), - send: vi.fn(), - on: vi.fn(), - removeListener: vi.fn(), - exposed: {} as Record -})) +import type { NodeTerminalApi, Project, SshPassphraseRequest } from '../shared/types' + +const h = vi.hoisted(() => { + const listeners = new Map void>>() + return { + invoke: vi.fn(async () => undefined), send: vi.fn(), + on: vi.fn((channel: string, fn: (...args: unknown[]) => void) => { + const set = listeners.get(channel) ?? new Set(); set.add(fn); listeners.set(channel, set) + }), + removeListener: vi.fn((channel: string, fn: (...args: unknown[]) => void) => listeners.get(channel)?.delete(fn)), + emit: (channel: string, ...args: unknown[]) => listeners.get(channel)?.forEach(fn => fn({ sender: 'test-IPC' }, ...args)), + exposed: {} as Record + } +}) vi.mock('electron', () => ({ contextBridge: { @@ -36,6 +41,10 @@ import './index' const api = h.exposed.nodeTerminal as NodeTerminalApi describe('preload sshProject passphrase wiring', () => { + it('lists pairing adapters only through the local pairing channel', async () => { + await api.pairing.listNetworks() + expect(h.invoke).toHaveBeenCalledWith(IPC.pairingListNetworks) + }) it('routes foreground process termination through request IPC', async () => { await api.pty.terminateForeground('node-1', 'claude') expect(h.invoke).toHaveBeenCalledWith(IPC.ptyTerminateForeground, 'node-1', 'claude') @@ -137,6 +146,67 @@ describe('preload sshProject passphrase wiring', () => { }) }) +describe('preload same-core project updates', () => { + const project: Project = { + id: 'p1', name: 'Board', color: '#10a37f', viewport: { x: 0, y: 0, zoom: 1 }, nodes: [], + kanban: { columns: [{ id: 'kcol-progress', title: 'In Progress', color: '#10a37f' }], assignments: [] } + } + + it('delivers phone Board updates without exposing the Electron event and unsubscribes exactly', () => { + const seen: Project[] = [] + const off = api.workspace.onServerChange(p => seen.push(p)) + const [channel, handler] = h.on.mock.calls.at(-1)! + expect(channel).toBe(IPC.workspaceServerChange) + h.emit(IPC.workspaceServerChange, project) + expect(seen).toEqual([project]) + off() + expect(h.removeListener).toHaveBeenLastCalledWith(IPC.workspaceServerChange, handler) + h.emit(IPC.workspaceServerChange, { ...project, name: 'After unsubscribe' }) + expect(seen).toEqual([project]) + }) + + it('keeps outside file edits separate from this core’s Board adoption channel', () => { + const own: Project[] = [], external: Project[] = [] + const offOwn = api.workspace.onServerChange(p => own.push(p)) + const offExternal = api.workspace.onExternalChange(p => external.push(p)) + h.emit(IPC.workspaceServerChange, project) + expect(own).toEqual([project]); expect(external).toEqual([]) + const pulled = { ...project, name: 'Git pull' } + h.emit(IPC.workspaceExternalChange, pulled) + expect(own).toEqual([project]); expect(external).toEqual([pulled]) + offOwn(); offExternal() + }) + + it('removing one renderer subscription leaves the other live', () => { + const first: Project[] = [], second: Project[] = [] + const offFirst = api.workspace.onServerChange(p => first.push(p)) + const offSecond = api.workspace.onServerChange(p => second.push(p)) + offFirst() + h.emit(IPC.workspaceServerChange, project) + expect(first).toEqual([]); expect(second).toEqual([project]) + offSecond() + h.emit(IPC.workspaceServerChange, project) + expect(second).toEqual([project]) + }) +}) + + +describe('sleeping wake ownership boundary', () => { + it('invokes the narrow core request and preserves automatic versus explicit wake events', async () => { + const request = { nodeId: 'term-s', agentId: 'codex', command: 'codex resume s', exitedByUs: true, recorded: { panePid: 41, command: 'bash' } } + await api.pty.wakeSleeping(request) + expect(h.invoke).toHaveBeenCalledWith(IPC.ptyWakeSleeping, request) + const listener = vi.fn() + const off = api.onAgentWake(listener) + h.emit(IPC.agentWake, 'term-s', true) + h.emit(IPC.agentWake, 'term-s') + h.emit(IPC.agentWake, 'term-s', 'true') + expect(listener.mock.calls).toEqual([['term-s', true], ['term-s', false], ['term-s', false]]) + off(); h.emit(IPC.agentWake, 'term-s', true) + expect(listener).toHaveBeenCalledTimes(3) + }) +}) + describe('preload canvasAuthority (the desktop governs nothing)', () => { it('answers no project, never assumes any, and never asks main', async () => { h.invoke.mockClear() diff --git a/src/preload/index.ts b/src/preload/index.ts index e389a58683..c9f90e7e0e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -109,6 +109,7 @@ const api: NodeTerminalApi = { readScrollback: (persistKey) => ipcRenderer.invoke(IPC.ptyReadScrollback, persistKey), sendText: (persistKey, text, opts) => ipcRenderer.invoke(IPC.ptySendText, persistKey, text, opts?.enter), + wakeSleeping: (request) => ipcRenderer.invoke(IPC.ptyWakeSleeping, request), sendChatPrompt: (persistKey, text, agentId) => ipcRenderer.invoke(IPC.ptySendChatPrompt, persistKey, text, agentId), tmuxStatus: () => ipcRenderer.invoke(IPC.ptyTmuxStatus), @@ -178,11 +179,13 @@ const api: NodeTerminalApi = { ipcRenderer.on(IPC.workspaceExternalChange, h) return () => ipcRenderer.removeListener(IPC.workspaceExternalChange, h) }, - // Deliberate no-op on the desktop shell: nothing here writes the project file on an agent's - // behalf. `HeadlessNodeFactory` is Server Edition only — the desktop's canvas-control verbs run - // through the renderer's own React Flow state, and its watcher path stays on onExternalChange. - // A real subscription would be dead wiring for a channel this main process never broadcasts. - onServerChange: (_cb: (project: Project) => void) => () => {} + // This core's phone Board edits use the same live-adoption path as Server Edition canvas + // control. Unlike outside file edits, they must not wait behind a dirty-canvas conflict. + onServerChange: (cb: (project: Project) => void) => { + const h = (_e: unknown, p: Project) => cb(p) + ipcRenderer.on(IPC.workspaceServerChange, h) + return () => ipcRenderer.removeListener(IPC.workspaceServerChange, h) + } }, projectSettings: { read: (projectId: string) => ipcRenderer.invoke(IPC.projectSettingsRead, projectId), @@ -750,6 +753,7 @@ const api: NodeTerminalApi = { ipcRenderer.invoke(IPC.handoffBuild, sessionId, agentId, sourceNodeId, cwd, accountId) }, pairing: { + listNetworks: () => ipcRenderer.invoke(IPC.pairingListNetworks), start: () => ipcRenderer.invoke(IPC.pairingStart), stop: () => ipcRenderer.invoke(IPC.pairingStop), onDone: (cb) => { @@ -888,7 +892,7 @@ const api: NodeTerminalApi = { reportHibernated: (nodeId, on) => ipcRenderer.send(IPC.agentHibernated, { nodeId, on }), seedAgentIdentity: (entries) => ipcRenderer.send(IPC.agentSeedIdentity, entries), onAgentWake: (listener) => { - const handler = (_e: unknown, nodeId: string) => listener(nodeId) + const handler = (_e: unknown, nodeId: string, automatic?: unknown) => listener(nodeId, automatic === true) ipcRenderer.on(IPC.agentWake, handler) return () => ipcRenderer.removeListener(IPC.agentWake, handler) }, diff --git a/src/renderer/bridge/stubs.test.ts b/src/renderer/bridge/stubs.test.ts index cc918f19c5..8d783ed7b1 100644 --- a/src/renderer/bridge/stubs.test.ts +++ b/src/renderer/bridge/stubs.test.ts @@ -3,6 +3,9 @@ import { buildStubApi, unsupported } from './stubs' import { E_UNSUPPORTED } from '../../shared/rpc' describe('bridge stubs', () => { + it('keeps computer pairing-network selection unsupported in the browser Server shell', async () => { + await expect(buildStubApi().pairing.listNetworks()).rejects.toMatchObject({ code: E_UNSUPPORTED }) + }) it('unsupported rejects with a coded error', async () => { await expect(unsupported('x.y')).rejects.toMatchObject({ code: E_UNSUPPORTED }) }) diff --git a/src/renderer/bridge/stubs.ts b/src/renderer/bridge/stubs.ts index b450be182b..24f05ab055 100644 --- a/src/renderer/bridge/stubs.ts +++ b/src/renderer/bridge/stubs.ts @@ -522,6 +522,7 @@ export function buildStubApi(): Omit< build: U('handoff.build') }, pairing: { + listNetworks: U('pairing.listNetworks'), start: U('pairing.start'), stop: U('pairing.stop'), onDone: noopUnsub, diff --git a/src/renderer/bridge/ws-bridge.ts b/src/renderer/bridge/ws-bridge.ts index 8df46c1e0b..b1791adfcd 100644 --- a/src/renderer/bridge/ws-bridge.ts +++ b/src/renderer/bridge/ws-bridge.ts @@ -291,6 +291,7 @@ export function buildRealApi( client.request(IPC.ptyReadScrollback, persistKey) as Promise, sendText: (persistKey, text, opts) => client.request(IPC.ptySendText, persistKey, text, opts?.enter) as Promise, + wakeSleeping: (request) => client.request(IPC.ptyWakeSleeping, request).catch(() => ({ delivered: false, verdict: 'delivery-failed' })) as Promise, sendChatPrompt: (persistKey, text, agentId) => client.request(IPC.ptySendChatPrompt, persistKey, text, agentId) as Promise, // A failed read is unknown, never evidence that persistence is available. @@ -363,9 +364,8 @@ export function buildRealApi( // Remote-node adoption (the phone appending a session it started) rides it too: that IS // "another device", which is what this channel means. onExternalChange: (cb) => client.subscribe(IPC.workspaceExternalChange, cb as Listener), - // REAL: Server Edition canvas control broadcasts its own persisted bridge/rope changes here — - // wider than the node-only canvas:mut vocabulary, but ours, so they must not travel the - // outside-edit channel and end up behind the conflict bar (see server/canvas-control.ts). + // REAL: this core's phone Board edits and Server Edition canvas control broadcast persisted + // changes here. They must not travel the outside-edit channel and wait behind a conflict bar. onServerChange: (cb) => client.subscribe(IPC.workspaceServerChange, cb as Listener) } diff --git a/src/renderer/canvas/Canvas.tsx b/src/renderer/canvas/Canvas.tsx index 011d2fd9eb..2898d24016 100644 --- a/src/renderer/canvas/Canvas.tsx +++ b/src/renderer/canvas/Canvas.tsx @@ -1,3 +1,4 @@ +import { wakeOffscreenNode } from '../terminal/offscreen-wake-runtime' import { PrepareUpdateDialog } from '../components/PrepareUpdateDialog' import type { PrepNode } from '../lib/updatePrep' import { reportTextDelivery } from '../lib/textDelivery' @@ -3920,8 +3921,8 @@ export function Canvas() { // Outside edits to a project's .nodeterm file (git pull / sync / teammate / another machine / // the phone registering a session it started). Writes THIS core made on an agent's behalf are - // not in here any more: Server Edition canvas control broadcasts them on `onServerChange` - // (below), because they are ours and there is nothing for the user to choose between. + // not in here any more: phone Board edits and Server Edition canvas control broadcast them on + // `onServerChange` (below), because they are ours and there is nothing to choose between. useEffect(() => { return api.workspace.onExternalChange((project) => { const { activeProjectId: current } = useProjects.getState() @@ -3966,8 +3967,9 @@ export function Canvas() { }) }, [reloadActiveProject, adoptIncomingNodes]) - // Writes this core made ITSELF: Server Edition headless canvas control (an agent ran - // `nodeterm open-agent`, `rename`, `close`…). Never a bar and never a reload — see + // Writes this core made ITSELF: phone Board moves/labels/default seeding on Desktop and Server, + // plus Server Edition headless canvas control (`open-agent`, `rename`, `close`…). Never a bar + // and never a reload — see // lib/serverChange.ts for why the outside-edit classifier was the wrong instrument, and // server/canvas-control.ts for the channel split. useEffect(() => { @@ -7179,16 +7181,17 @@ export function Canvas() { // Eco × phone ("hibernation isn't phone-compatible"). Three wires, all nudges: // - `agent:wake`: a phone viewer just attached over the relay to a node's session — ask the // node to resume its hibernated CLI. Same contract as every other wake asker: the node - // re-reads the flag itself, so this is a no-op for a non-hibernated node, and a no-op for an - // UNMOUNTED one (inactive project) — the phone then still sees the SLEEPING chip and an - // honest shell, never a resume typed into a pane nothing verified. + // re-reads the flag itself; an unmounted node resolves its saved owner and spends the same + // pane proof without switching projects. Automatic attach never resumes a paused node. // - `agent:remote-viewers`: the full set of phone-watched node ids, fed into `isNodeWatched` // so the sweep cannot `/exit` a session someone is reading on their phone. // - boot replay: the hibernated flag is persisted in THIS renderer's localStorage, and main's // mirror starts empty every launch — re-report the persisted set once so the phone's // SLEEPING chips survive a desktop restart. useEffect(() => { - const unsubWake = window.nodeTerminal.onAgentWake?.((nodeId) => wakeHibernatedNode(nodeId)) + const unsubWake = window.nodeTerminal.onAgentWake?.((nodeId, automatic) => { + if (!wakeHibernatedNode(nodeId, automatic)) void wakeOffscreenNode(nodeId, automatic) + }) const unsubViewers = window.nodeTerminal.onRemoteViewers?.((ids) => setRemotelyViewedNodes(ids) ) @@ -16362,7 +16365,8 @@ export function Canvas() { e.verified, e.errored, e.held, - recordsTurnInterrupt(e) + recordsTurnInterrupt(e), + e.permissionSuggestions ) // Claude's Stop names the BACKGROUND tasks still running (async subagents, nested ones, // background shells). A background subagent that ends its turn while its own work runs @@ -18546,6 +18550,7 @@ export function Canvas() { /> ) : perProjectKanbanOpen && ( ))} + {!ANDROID_APP_PUBLISHED && ( + + )} diff --git a/src/renderer/components/androidAppLink.test.tsx b/src/renderer/components/androidAppLink.test.tsx new file mode 100644 index 0000000000..173830cbbf --- /dev/null +++ b/src/renderer/components/androidAppLink.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +// +// The Android link on both phone surfaces (Settings → Phone and the quick-pair popover). +// +// There is no store listing and no published APK: the link opens the repo's `android/` source +// folder. It sits beside an App Store link and after "Don't have the app yet?", so a bare +// "nodeterm for Android" reads as a download (audit A66). Until a signed release APK exists, the +// label must say it is source, and the URL must come from the one repository constant. +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { PhoneSection } from './settings/sections/PhoneSection' +import { PhonePairPopover } from './PhonePairPopover' +import { REPO_URL } from '@renderer/lib/bugReport' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +vi.mock('qrcode', () => ({ toDataURL: async () => 'data:image/png;base64,QR' })) + +let root: Root +let host: HTMLElement +let openExternal: ReturnType + +function stubBridge(): void { + openExternal = vi.fn() + ;(window as unknown as { nodeTerminal: unknown }).nodeTerminal = { + pairing: { + start: vi.fn(async () => ({ payload: '{"v":1}', sshOpen: true, sshKey: true, relayPlan: 'ok' })), + stop: vi.fn(async () => undefined), + onDone: vi.fn(() => () => undefined), + probeSsh: vi.fn(async () => true), + openRemoteLoginSettings: vi.fn(), + listDevices: vi.fn(async () => []), + revokeDevice: vi.fn() + }, + remoteHost: { setPhoneAccess: vi.fn() }, + shell: { openExternal } + } +} + +/** The one button naming Android; clicking it must open the repo's `android/` folder. */ +function androidLink(): HTMLButtonElement { + const buttons = [...document.body.querySelectorAll('button')].filter((b) => + /Android/.test(b.textContent ?? '') + ) + expect(buttons).toHaveLength(1) + act(() => buttons[0].click()) + expect(openExternal).toHaveBeenCalledWith(`${REPO_URL}/tree/main/android`) + return buttons[0] +} + +beforeEach(() => { + stubBridge() + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.restoreAllMocks() +}) + +describe('the Android app link says it is source, not an installable', () => { + it('Settings → Phone', async () => { + act(() => root.render()) + await act(async () => undefined) + expect(androidLink().textContent).toMatch(/build from source/) + }) + + it('the quick-pair popover', async () => { + act(() => + root.render( + undefined} + onOpenSettings={() => undefined} + /> + ) + ) + await act(async () => undefined) + expect(androidLink().textContent).toMatch(/build from source/) + }) +}) + +describe('lib/links', () => { + it('derives the Android link from REPO_URL instead of spelling the repository out again', () => { + const src = readFileSync(join(__dirname, '..', 'lib', 'links.ts'), 'utf8').replace(/\r\n/g, '\n') + const code = src + .split('\n') + .filter((l) => !/^\s*(\/\/|\/\*|\*)/.test(l)) + .join('\n') + expect(code).not.toMatch(/github\.com/) + }) +}) diff --git a/src/renderer/components/kanban/CardModal.tsx b/src/renderer/components/kanban/CardModal.tsx index 75b17ba872..8c09fd437c 100644 --- a/src/renderer/components/kanban/CardModal.tsx +++ b/src/renderer/components/kanban/CardModal.tsx @@ -84,6 +84,8 @@ const ChatPanel = lazy(() => import('../../nodes/ChatPanel').then((m) => ({ defa interface CardModalProps { session: KanbanSession + /** The card's canvas owner, including an inactive global-board swimlane. */ + ownerProjectId?: string /** The project whose board this card is on — the active one on the per-project board, the lane's * on the Omni board. The modal sits OUTSIDE that project's `SessionProvider` (`useSession()` * here is the app's local session), so anything that depends on which machine the node runs @@ -140,7 +142,7 @@ interface CardModalProps { * canvas under it) stay mounted. Terminal cards carry the node header's actions too: * search / dictate / AI-name / the ⌘M view — ChatPanel or the output markdown, the same face the * canvas node shows (the node itself is hidden under the board). */ -export function CardModal({ session, projectId, projectName, projectColor, columnTitle, board, onChangeBoard, onClose, portsProjectId, onOpenCanvas, onRename, onEditSticky, onBrowserNav, onSetIcon, onOpenIssue, mentionables, team, onTravel, initialView, onSetColor, onDelete, onAiName }: CardModalProps) { +export function CardModal({ session, ownerProjectId, projectId, projectName, projectColor, columnTitle, board, onChangeBoard, onClose, portsProjectId, onOpenCanvas, onRename, onEditSticky, onBrowserNav, onSetIcon, onOpenIssue, mentionables, team, onTravel, initialView, onSetColor, onDelete, onAiName }: CardModalProps) { const { api } = useSession() // The header slot decides "icon or smiley" on the NORMALIZED value, the answer NodeIconView // itself gives — on the raw one, an invalid stored icon drew an empty, un-muted slot. @@ -755,6 +757,7 @@ export function CardModal({ session, projectId, projectName, projectColor, colum setSearchOpen(false)} @@ -781,10 +784,10 @@ export function CardModal({ session, projectId, projectName, projectColor, colum pathsForFiles={(files) => droppedPaths(files, { sshRemoteTmux: !!session.spawn.sshRemoteTmux, - projectId: session.spawn.sshRemoteTmux ? nodeUploadScope(session.spawn.ssh) : '' + projectId: session.spawn.sshRemoteTmux ? nodeUploadScope(session.spawn.ssh, ownerProjectId ?? projectId) : '' }) } - sshProjectId={session.spawn.sshRemoteTmux ? nodeUploadScope(session.spawn.ssh) : undefined} + sshProjectId={session.spawn.sshRemoteTmux ? nodeUploadScope(session.spawn.ssh, ownerProjectId ?? projectId) : undefined} onShowTerminal={() => { // The picker just opened in the live viewer needs the keyboard. requestTerminalFocusOnExit(session.id) diff --git a/src/renderer/components/kanban/GlobalKanbanView.tsx b/src/renderer/components/kanban/GlobalKanbanView.tsx index f6bb82f3a4..63ec29ab86 100644 --- a/src/renderer/components/kanban/GlobalKanbanView.tsx +++ b/src/renderer/components/kanban/GlobalKanbanView.tsx @@ -341,6 +341,7 @@ const Swimlane = memo(function Swimlane({ projectName={projectName} projectColor={projectColor} session={byId.get(modalNodeId)!} + ownerProjectId={projectId} projectId={projectId} columnTitle={columnForNode(board, modalNodeId)?.title ?? null} board={board} diff --git a/src/renderer/components/kanban/KanbanView.tsx b/src/renderer/components/kanban/KanbanView.tsx index 8a2be9084e..37ed13fc30 100644 --- a/src/renderer/components/kanban/KanbanView.tsx +++ b/src/renderer/components/kanban/KanbanView.tsx @@ -111,6 +111,7 @@ export interface KanbanCreateOption { } export interface KanbanViewProps { + ownerProjectId?: string board: ProjectKanban sessions: KanbanSession[] onChange: (next: ProjectKanban) => void @@ -240,7 +241,7 @@ function useCanvasCovered(): void { } export const KanbanView = memo(function KanbanView({ - board, sessions, onChange, onOpenNode, onCreateNode, onRenameNode, onEditSticky, onDeleteNode, + ownerProjectId, board, sessions, onChange, onOpenNode, onCreateNode, onRenameNode, onEditSticky, onDeleteNode, onModalNodeChange, onBrowserNav, onSetIcon, nodeActionItems, onAiName, onSetColor, onAutoMoveFromPulls, issueAgentMenu, issueWorktreeMenu, teams, onIssueMoved }: KanbanViewProps) { @@ -1270,6 +1271,7 @@ export const KanbanView = memo(function KanbanView({ projectName={projectName} projectColor={projectColor} session={byId.get(modalNodeId)!} + ownerProjectId={ownerProjectId} projectId={projectId} mentionables={mentionables} columnTitle={columnForNode(board, modalNodeId)?.title ?? null} diff --git a/src/renderer/components/kanban/ModalTerminal.test.tsx b/src/renderer/components/kanban/ModalTerminal.test.tsx new file mode 100644 index 0000000000..511cd9c1ed --- /dev/null +++ b/src/renderer/components/kanban/ModalTerminal.test.tsx @@ -0,0 +1,368 @@ +// @vitest-environment jsdom +// Exercise the mounted view and its real LocalTransport/coordinator. Only terminal rendering +// and peripheral panels are fakes; PTY requests, subscriptions and viewer identities are observed. +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NodeTerminalApi, PtyCreateOptions, PtyCreateResult, Project } from '@shared/types' +import type { Terminal } from '@xterm/xterm' +import { bindXtermInput } from '../../terminal/xterm-input' +import { sshAttachmentId } from '@shared/ssh' +import { ModalTerminal, type ModalSpawn } from './ModalTerminal' +import { GlobalKanbanView } from './GlobalKanbanView' +import { useProjects } from '../../state/projects' +import { useSshConn } from '../../state/sshConn' +import { useSettings } from '../../state/settings' +import { useViewMode } from '../../state/viewMode' +import { resetHostAttachmentDials } from '../../lib/sshAttachments' +import { SshReconnector, RECONNECT_DELAYS_MS } from '../../lib/sshReconnect' +import { setSshDropHandler } from '../../nodes/TerminalNode' +import { resetDialogStack } from '../dialog-stack' + +const boundary = vi.hoisted(() => { + const terminals: FakeTerminal[] = [] + class FakeTerminal { + cols = 80 + rows = 24 + options = {} + unicode = { activeVersion: '6' } + buffer = { active: { length: 0, getLine: () => undefined } } + parser = { registerOscHandler: vi.fn() } + write = vi.fn((data: string) => { + if (data.includes('\x1b[c')) this._core.coreService.triggerDataEvent('\x1b[?1;2c') + if (data.includes('\x1b[>c')) this._core.coreService.triggerDataEvent('\x1b[>0;276;0c') + }) + resize = vi.fn() + focus = vi.fn() + dispose = vi.fn() + input: ((data: string) => void) | undefined + _core = { + coreService: { triggerDataEvent: (data: string, _user = false): void => { this.input?.(data) } }, + _handleTextAreaFocus: (): void => { this._core.coreService.triggerDataEvent('\x1b[I') }, + _handleTextAreaBlur: (): void => { this._core.coreService.triggerDataEvent('\x1b[O') } + } + constructor() { terminals.push(this) } + open(): void {} + loadAddon(): void {} + registerLinkProvider(): void {} + attachCustomKeyEventHandler(): void {} + hasSelection(): boolean { return false } + getSelection(): string { return '' } + onData(listener: (data: string) => void): { dispose(): void } { + this.input = listener + return { dispose: () => { if (this.input === listener) this.input = undefined } } + } + } + return { api: null as unknown, terminals, Terminal: FakeTerminal } +}) + +vi.mock('@xterm/xterm', () => ({ Terminal: boundary.Terminal })) +vi.mock('@xterm/addon-fit', () => ({ FitAddon: class { fit(): void {} } })) +vi.mock('@xterm/addon-search', () => ({ SearchAddon: class { + onDidChangeResults(): () => void { return () => {} } + findNext(): void {} + findPrevious(): void {} + clearDecorations(): void {} +} })) +vi.mock('../../session/session', () => ({ useSession: () => ({ api: boundary.api, source: 'local' }) })) +vi.mock('../../terminal/useContextEnsure', () => ({ useContextEnsure: () => {} })) +vi.mock('../../terminal/useXtermVisualSettings', () => ({ + useXtermVisualSettings: () => ({ terminalTheme: 'nord', fontFamily: 'monospace', fontSize: 14 }) +})) +vi.mock('../../lib/useTerminalGlass', () => ({ useTerminalGlass: () => ({ glass: false, vars: null }) })) +vi.mock('../../terminal/useCopyFeedback', () => ({ useCopyFeedback: () => ({ notifyCopy: () => {} }) })) +vi.mock('./BoardLogPanel', () => ({ BoardLogPanel: () => null })) +vi.mock('./CardMetaBar', async (importOriginal) => ({ + ...await importOriginal(), CardMetaBar: () => null +})) +vi.mock('../ContextMeter', () => ({ ContextMeter: () => null })) + +const conn = { host: 'qa-host', user: 'tester', port: 29451 } +const nodeId = 'term-qa-1' +const spawn: ModalSpawn = { shell: '/bin/bash', cwd: '/qa', ssh: conn, sshRemoteTmux: true } +let root: Root +let host: HTMLDivElement +let rec: SshReconnector +let drop: ReturnType void>> +let create: ReturnType Promise>> +let kill: ReturnType +let write: ReturnType +let connect: ReturnType +let exits: Map void> +let outputs: Map void> +let terminations: Map void> +const peerCleanups: Array<() => void> = [] +const result = (id: string): PtyCreateResult => ({ sessionId: id, fresh: false, screen: 'warm\n' }) +const project = (id: string, nodes: Project['nodes'] = [], remote = false): Project => ({ + id, name: id, color: '#fff', nodes, viewport: { x: 0, y: 0, zoom: 1 }, + ...(remote ? { ssh: { server: conn, remoteCwd: '/qa' } } : {}) +}) +async function settle(): Promise { + await act(async () => { await Promise.resolve(); await Promise.resolve() }) +} +async function mount(props: { ownerProjectId?: string; spawn?: ModalSpawn } = {}): Promise { + await act(async () => root.render( {}} />)) + await settle() +} +async function reconnect(scope: string): Promise { + await act(async () => { await vi.advanceTimersByTimeAsync(RECONNECT_DELAYS_MS[0]) }) + await settle() + expect(drop).toHaveBeenCalledWith(scope, nodeId) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.stubGlobal('ResizeObserver', class { observe(): void {} disconnect(): void {} }) + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener() {}, removeEventListener() {} })) + resetDialogStack() + resetHostAttachmentDials() + boundary.terminals.length = 0 + exits = new Map() + outputs = new Map() + terminations = new Map() + kill = vi.fn() + write = vi.fn() + create = vi.fn(async () => result(`session-${create.mock.calls.length}`)) + connect = vi.fn(async () => ({ controlPath: '/private/cm' })) + boundary.api = { + pty: { + create, kill, write, resize: vi.fn(), readScrollback: vi.fn(async () => ''), capture: vi.fn(async () => ''), + tmuxStatus: vi.fn(async () => ({ platform: 'linux' })), + remoteSessionConfirmed: vi.fn(async () => true), + onData: (id: string, cb: (data: string) => void) => { outputs.set(id, cb); return () => { outputs.delete(id) } }, + onExit: (id: string, cb: (code: number) => void) => { exits.set(id, cb); return () => { exits.delete(id) } }, + onClosed: (id: string, cb: () => void) => { terminations.set(`closed:${id}`, cb); return () => { terminations.delete(`closed:${id}`) } }, + onRecycled: (id: string, cb: () => void) => { terminations.set(`recycled:${id}`, cb); return () => { terminations.delete(`recycled:${id}`) } }, + onSize: () => () => {} + }, + sshProject: { connect, disconnect: vi.fn(async () => {}) }, + shell: { openExternal: vi.fn() }, clipboard: { writeText: vi.fn() }, + onMarkdownToggle: () => () => {}, + settings: { save: vi.fn(async () => {}) }, boardLog: { list: async () => [], onChanged: () => () => {} } + } as unknown as NodeTerminalApi + window.nodeTerminal = boundary.api as NodeTerminalApi + useProjects.setState({ projects: [project('owner', [], true), project('active')], activeProjectId: 'active' }) + useSshConn.setState({ byProject: { owner: { controlPath: '/private/cm' } }, earlyByProject: {}, attachments: {} }) + useViewMode.setState({ requestedCardNodeId: null, globalKanban: false }) + rec = new SshReconnector({ connect: async () => true, respawn: vi.fn() }) + drop = vi.fn((scope: string, node: string) => rec.reportDrop(scope, node)) + setSshDropHandler(drop) + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) +}) +afterEach(async () => { + await act(async () => root.unmount()) + peerCleanups.splice(0).forEach((off) => off()) + rec.dispose() + setSshDropHandler(null) + resetHostAttachmentDials() + resetDialogStack() + host.remove() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('ModalTerminal SSH reconnect', () => { + it('keeps the card view mounted while replacing only its lost viewer and using the new session', async () => { + await mount({ spawn: { ...spawn, initialCommand: 'claude --resume held-intent' } }) + const first = create.mock.calls[0][0] + const retiredExit = exits.get('session-1')! + act(() => retiredExit(255)) + await reconnect('owner') + expect(create).toHaveBeenCalledTimes(2) + const second = create.mock.calls[1][0] + expect(second.persistKey).toBe(nodeId) + expect(second.requireRemote).toBe(true) + expect(second.ownerProjectId).toBe('owner') + expect(second.sshRemote?.controlPath).toBe('/private/cm') + expect(second.viewerId).not.toBe(first.viewerId) + expect(kill.mock.calls).toEqual([['session-1', first.viewerId]]) + expect(exits.has('session-1')).toBe(false) + expect(outputs.has('session-1')).toBe(false) + act(() => outputs.get('session-2')!('after-reconnect')) + expect(boundary.terminals[1].write).toHaveBeenCalledWith('after-reconnect') + boundary.terminals[1].input!('user-input\r') + expect(write.mock.calls).toEqual([['session-2', 'user-input\r']]) + expect(second).not.toHaveProperty('initialCommand') + act(() => retiredExit(255)) + expect(drop).toHaveBeenCalledTimes(1) + await act(async () => root.unmount()) + expect(kill.mock.calls).toEqual([['session-1', first.viewerId], ['session-2', second.viewerId]]) + }) + + it('ignores healthy, different-scope and different-node reconnects', async () => { + await mount() + rec.reportDrop('owner', nodeId) + await act(async () => { await vi.advanceTimersByTimeAsync(RECONNECT_DELAYS_MS[0]) }) + expect(create).toHaveBeenCalledTimes(1) + act(() => exits.get('session-1')!(255)) + rec.reportDrop('foreign', nodeId) + rec.reportDrop('owner', 'other-node') + act(() => { rec.onConnected('foreign'); rec.onConnected('owner') }) + // n1 was refused by the existing hot-loop guard; only the unrelated nodes were flushed. + await settle() + expect(create).toHaveBeenCalledTimes(1) + }) + + it.each([ + ['local', { shell: '/bin/bash' }], + ['ordinary ssh program', { shell: 'ssh', ssh: conn }] + ])('does not turn %s exit255 into a ControlMaster reconnect', async (_name, localSpawn) => { + await mount({ spawn: localSpawn }) + act(() => exits.get('session-1')!(255)) + expect(drop).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledTimes(1) + }) + + it('does not reconnect a remote process that exits normally', async () => { + await mount() + act(() => exits.get('session-1')!(0)) + expect(drop).not.toHaveBeenCalled() + }) + + it('reattaches after a remote create refusal when the coordinator succeeds', async () => { + create.mockResolvedValueOnce({ sessionId: '', fresh: false, unavailable: 'ssh' }) + await mount() + await reconnect('owner') + expect(create).toHaveBeenCalledTimes(2) + expect(kill).not.toHaveBeenCalled() + }) + + it.each(['success', 'refusal'] as const)('retires an in-flight %s without reviving a closed card', async (kind) => { + let finish!: (res: PtyCreateResult) => void + create.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve })) + await mount() + const viewer = create.mock.calls[0][0].viewerId + await act(async () => root.unmount()) + finish(kind === 'success' ? result('late-session') : { sessionId: '', fresh: false, unavailable: 'ssh' }) + await settle() + expect(drop).not.toHaveBeenCalled() + expect(kill.mock.calls).toEqual(kind === 'success' ? [['late-session', viewer]] : []) + expect(boundary.terminals[0].write).not.toHaveBeenCalled() + rec.reportDrop('owner', nodeId) + act(() => rec.onConnected('owner')) + await settle() + expect(create).toHaveBeenCalledTimes(1) + }) + + it('routes an inactive global-board card through its own host attachment and keeps its modal open', async () => { + useProjects.setState({ + projects: [project('owner', [{ + id: nodeId, kind: 'terminal', position: { x: 0, y: 0 }, + size: { width: 640, height: 480 }, color: '#fff', group: null, + shell: '/bin/bash', cwd: '/qa', ssh: conn, sshRemoteTmux: true, title: 'QA terminal' + }]), project('active')], + activeProjectId: 'active' + }) + useSshConn.setState({ byProject: {} }) + useSettings.setState((s) => ({ settings: { ...s.settings, omniKanbanEnabled: true } })) + await act(async () => root.render()) + const card = document.querySelector('[title="Open card"]')! + expect(card).toBeTruthy() + act(() => card.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + await settle() + const scope = sshAttachmentId('owner', conn) + expect(connect).toHaveBeenCalledWith(scope, conn, '/qa') + expect(useSshConn.getState().getAttachment(scope)?.ownerProjectId).toBe('owner') + expect(create.mock.calls[0][0].ownerProjectId).toBe('owner') + expect(create.mock.calls[0][0].sshRemote?.controlPath).toBe('/private/cm') + const modal = document.querySelector('.kanban-modal') + expect(modal).toBeTruthy() + act(() => exits.get('session-1')!(255)) + await reconnect(scope) + expect(document.querySelector('.kanban-modal')).toBe(modal) + expect(create).toHaveBeenCalledTimes(2) + expect(useProjects.getState().activeProjectId).toBe('active') + }) +}) + +describe('mounted Modal response-owner lifecycle with real LocalTransport', () => { + const query = '\x1b[c\x1b[>c' + const replies = ['\x1b[?1;2c', '\x1b[>0;276;0c'] + function peer(sessionId: string) { + const term = new boundary.Terminal() + const api = boundary.api as NodeTerminalApi + const off = bindXtermInput(term as unknown as Terminal, api.pty, sessionId, + (data) => api.pty.write(sessionId, data)) + peerCleanups.push(off) + return { term, off } + } + + it('owns replies before synchronous first output and create-screen seed are parsed', async () => { + const api = boundary.api as NodeTerminalApi + const onData = api.pty.onData + api.pty.onData = (sid, callback) => { + const off = onData(sid, callback) + callback(query) + return off + } + create.mockResolvedValueOnce({ ...result('session-1'), screen: query }) + await mount() + expect(write.mock.calls.map(([sid, data]) => [sid, data])).toEqual([ + ['session-1', replies[0]], ['session-1', replies[1]], + ['session-1', replies[0]], ['session-1', replies[1]] + ]) + }) + + it('does not duplicate its primary peer replies, but every view still sends reply-shaped user input', async () => { + const canvas = peer('session-1') + create.mockResolvedValueOnce({ ...result('session-1'), coAttachMouse: true }) + await mount() + const modal = boundary.terminals[1] + canvas.term.write(query) + outputs.get('session-1')!(query) + expect(write.mock.calls).toEqual(replies.map((r) => ['session-1', r])) + canvas.term._core.coreService.triggerDataEvent(replies.join(''), true) + modal._core.coreService.triggerDataEvent(replies.join(''), true) + expect(write.mock.calls.slice(2)).toEqual([ + ['session-1', replies.join('')], ['session-1', replies.join('')] + ]) + canvas.off() + outputs.get('session-1')!(query) + // The co-attach flag never permanently prevents the surviving Modal from answering. + expect(write.mock.calls.slice(4)).toEqual(replies.map((r) => ['session-1', r])) + }) + + it('retires an exited Modal before reattach and gives its old-generation peer the response lease', async () => { + await mount() + const oldModal = boundary.terminals[0] + const follower = peer('session-1') + act(() => exits.get('session-1')!(255)) + follower.term.write(query) + oldModal._core.coreService.triggerDataEvent('late user', true) + expect(write.mock.calls).toEqual(replies.map((r) => ['session-1', r])) + await reconnect('owner') + const replacement = boundary.terminals[2] + replacement._core.coreService.triggerDataEvent(replies[0]) + follower.term._core.coreService.triggerDataEvent(replies[1]) + expect(write.mock.calls.slice(2)).toEqual([ + ['session-2', replies[0]], ['session-1', replies[1]] + ]) + expect(kill).toHaveBeenCalledWith('session-1', expect.stringMatching(/^modal-/)) + }) + + it('releases only the closing Modal and immediately promotes a surviving same-session peer', async () => { + await mount() + const modal = boundary.terminals[0], follower = peer('session-1') + await act(async () => root.render(null)) + modal._core.coreService.triggerDataEvent('closed', true) + follower.term.write(query) + expect(write.mock.calls).toEqual(replies.map((r) => ['session-1', r])) + expect(kill).toHaveBeenCalledTimes(1) + expect(kill).toHaveBeenCalledWith('session-1', expect.stringMatching(/^modal-/)) + }) + + it.each(['closed', 'recycled'])('retires a %s generation without requiring an exit event or replaying a launch', async (event) => { + await mount({ spawn: { ...spawn, initialCommand: 'never replay this' } }) + const modal = boundary.terminals[0], follower = peer('session-1') + act(() => terminations.get(`${event}:session-1`)!()) + modal._core.coreService.triggerDataEvent('retired', true) + follower.term.write(query) + expect(write.mock.calls).toEqual(replies.map((r) => ['session-1', r])) + expect(create).toHaveBeenCalledTimes(1) + expect(drop).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/components/kanban/ModalTerminal.tsx b/src/renderer/components/kanban/ModalTerminal.tsx index a67f1777c8..6f68355d4c 100644 --- a/src/renderer/components/kanban/ModalTerminal.tsx +++ b/src/renderer/components/kanban/ModalTerminal.tsx @@ -4,6 +4,7 @@ import { FIND_DECORATIONS } from '../../lib/palette' import { ptyRefusal } from '@shared/pty-refusal' import { patchImeModeSwitch } from '../../terminal/ime-mode-switch' +import { bindXtermInput } from '../../terminal/xterm-input' import { useCallback, useEffect, useRef, useState } from 'react' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' @@ -14,6 +15,7 @@ import type { AgentId } from '@shared/agents/config' import { effectiveAccountId } from '../../lib/accountChip' import { readsClaudeTranscript } from '../../lib/transcriptGates' import { liveProjectJumpTarget } from '../../lib/projectJump' +import { isBrowserRuntime } from '../../bridge/runtime' import { terminalChordBubbles, terminalShortcutPolicy } from '../../lib/keybindingOverrides' import { focusXtermUnlessCovered, useMdModeFocus } from '../../terminal/useMdModeFocus' import { FindBar } from '../FindBar' @@ -45,7 +47,6 @@ import { makeDirListingLookup, type UnverifiedPath } from '../../terminal/file-links' -import { isBrowserRuntime } from '../../bridge/runtime' import { fileLinkDialect } from '../../terminal/file-link-dialect' import { hostPlatformFor } from '../../terminal/host-platform' import { sshFs } from '../../terminal/ssh-fs' @@ -84,6 +85,7 @@ import { sshConnectionScope } from '../../nodes/TerminalNode' import { buildSshArgs, type SshConnection } from '@shared/ssh' +import { subscribeSshReattach } from '../../lib/sshReconnect' /** The subset of a node's `data` a SECOND client needs to attach to its session the same way the * canvas TerminalNode does. Canvas fills it from the node's data; sticky/chat cards pass `{}`. */ @@ -119,6 +121,7 @@ export interface ModalSpawn { */ interface ModalTerminalProps { nodeId: string + ownerProjectId?: string spawn: ModalSpawn /** The modal header's 🔍 toggle — the FindBar renders inside this pane. */ searchOpen: boolean @@ -142,6 +145,7 @@ interface ModalTerminalProps { export function ModalTerminal({ nodeId, + ownerProjectId: explicitOwnerProjectId, spawn, searchOpen, onCloseSearch, @@ -151,6 +155,11 @@ export function ModalTerminal({ }: ModalTerminalProps) { const session = useSession() const { api } = session + const ownerProjectId = explicitOwnerProjectId ?? projectId ?? owningProjectId() + const [attachRevision, setAttachRevision] = useState(0) + const sshScopeId = spawn.sshRemoteTmux && spawn.ssh + ? sshConnectionScope(spawn.ssh, ownerProjectId) + : null // Read at click time from the lifecycle effect, which runs once per mount. const projectIdRef = useRef(projectId) projectIdRef.current = projectId @@ -205,12 +214,12 @@ export function ModalTerminal({ useContextEnsure(api.context, nodeId, spawn.agentId ?? observedAgentId, transcript.sessionId, transcript.cwd, effectiveAccountId(spawn.accountId, observedAccount, claudeAccounts)) // One shallow-compared subscription for the whole appearance slice — see useXtermVisualSettings. - // MIRROR TerminalNode: scoped to the OWNING project (`owningProjectId`, the active one — a modal - // only ever opens over it), deliberately NOT this card's connection scope. `sshConnectionScope` + // Scoped to the card's OWNING project, including an inactive global-board lane, + // deliberately NOT this card's connection scope. `sshConnectionScope` // answers a project×host attachment id for a session on a foreign host, which names no project at // all — the per-project appearance would silently vanish for exactly those cards. // …and the NODE's own font size (issue #915), so this second view matches the canvas one. - const visual = useXtermVisualSettings(owningProjectId(), spawn.terminalFontSize) + const visual = useXtermVisualSettings(ownerProjectId, spawn.terminalFontSize) // MIRROR TerminalNode: the xterm is BUILT from the effective appearance, not the bare globals. const visualRef = useRef(visual) visualRef.current = visual @@ -306,7 +315,21 @@ export function ModalTerminal({ let sessionId: string | null = null let dead = false + let connectionLost = false const cleanups: Array<() => void> = [] + if (sshScopeId) { + cleanups.push(subscribeSshReattach(sshScopeId, nodeId, () => { + if (dead || !connectionLost) return + // Clear synchronously: two notifications before React commits still rebuild only once. + connectionLost = false + setAttachRevision((revision) => revision + 1) + })) + } + const reportConnectionLost = () => { + if (dead || !sshScopeId) return + connectionLost = true + reportSshDrop(sshScopeId, nodeId) + } // MIRROR TerminalNode's link wiring. The URL provider handles Cmd/Ctrl+click on URL text when // mouse-reporting is off (plain-shell sessions); the capture-phase fallback is what works under @@ -472,11 +495,18 @@ export function ModalTerminal({ // focus etc. mean nothing here), so those chords keep reaching the pty; app-scope // allowInTerminal commands still bubble, matching what the dispatcher would claim. const registryOwns = terminalChordBubbles(e, true) - const action = terminalKeyAction(e, term.hasSelection(), ownsProjectJump, registryOwns) + const action = terminalKeyAction( + e, + term.hasSelection(), + ownsProjectJump, + registryOwns, + undefined, + { isDesktop: !isBrowserRuntime(), isMac: isMacPlatform() } + ) if (action === 'pass') return true // 'bubble': hand the chord to the window dispatcher — no preventDefault (it bails on // defaultPrevented), no xterm processing. 'native' leaves the event uncancelled for the - // PLATFORM's own paste (Windows Ctrl+V). See TerminalNode's twin comment. + // Desktop Quit role or PLATFORM paste (Windows Ctrl+V). See TerminalNode's twin comment. if (action === 'bubble' || action === 'native') return false e.preventDefault() if (action === 'copy') window.nodeTerminal.clipboard.writeText(term.getSelection()) @@ -485,18 +515,12 @@ export function ModalTerminal({ }) void (async () => { - // Read here, not at click time: a modal only ever opens over the ACTIVE project, and the - // reconnect coordinator is keyed by CONNECTION SCOPE (same choice resolveSshRemote makes) — - // the project's own id, or the host attachment when this card's session is on a machine the - // project isn't. - const projectId = - spawn.sshRemoteTmux && spawn.ssh - ? sshConnectionScope(spawn.ssh) - : useProjects.getState().activeProjectId + // Resolve against the card's captured owner, including an inactive global-board lane. + // Drops and reattachment notifications use that owner's exact connection scope. // SSH-project node: resolve the live ControlMaster (may not be up yet on a cold load). const sshRemote = spawn.sshRemoteTmux && spawn.ssh - ? await resolveSshRemote(spawn.ssh, spawn.cwd, { nodeId, pty: api.pty }) + ? await resolveSshRemote(spawn.ssh, spawn.cwd, { nodeId, pty: api.pty }, ownerProjectId) : undefined if (dead) return // The host is unreachable: spawn NOTHING. A create with no `sshRemote` falls through to @@ -507,7 +531,7 @@ export function ModalTerminal({ term.write( `\r\n\x1b[90m[not connected — this session lives on ${spawn.ssh ? `${spawn.ssh.user}@${spawn.ssh.host}` : 'the remote host'}; nothing was started locally]\x1b[0m\r\n` ) - if (projectId) reportSshDrop(projectId, nodeId) + reportConnectionLost() return } // A local `ssh ` node runs ssh as its own pty program (shell:'ssh' + buildSshArgs); an @@ -525,17 +549,23 @@ export function ModalTerminal({ // project is not the active canvas), the pane must still record its true owner. Use the // CARD's project resolved above, NOT the active canvas id — the modal may be for a // non-active project. Recorded main-side only on a genuine fresh spawn. - ownerProjectId: projectId, + ownerProjectId, agentId: spawn.agentId, accountId: spawn.accountId, sshRemote, requireRemote: spawn.sshRemoteTmux }) + // A superseded generation must not paint or queue a drop, even for a refused create. + // Successful late creates still owe their exact viewer a detach. + if (dead) { + if (!res.unavailable && !res.closed) transport.kill(res.sessionId) + return + } // Refused core-side (the master died inside our round-trip, or `ssh` is missing). if (res.unavailable) { const refusal = ptyRefusal(res.unavailable) term.write(`\r\n\x1b[90m[${refusal.message}]\x1b[0m\r\n`) - if (refusal.connectionLost && projectId) reportSshDrop(projectId, nodeId) + if (refusal.connectionLost) reportConnectionLost() return } // Another client permanently deleted this node's session — never resurrect it (no live session @@ -544,19 +574,26 @@ export function ModalTerminal({ term.write('\r\n\x1b[90m[session closed by another user]\x1b[0m\r\n') return } - // Unmounted while the create was in flight: detach the viewer we just registered so it doesn't - // linger as a phantom subscriber constraining the shared pty's size. - if (dead) { - transport.kill(res.sessionId) - return - } sessionId = res.sessionId sessionIdRef.current = res.sessionId + // Acquire before output/seed parsing can produce terminal replies. Release only this view; + // a surviving co-viewer immediately becomes the response owner for this session generation. + const disposeInput = bindXtermInput(term, api.pty, res.sessionId, (d) => { + if (!dead) transport.write(res.sessionId, d) + }) + cleanups.push(disposeInput) + // Core removes subscribers before killing a destroyed/recycled generation, so its exit + // event need not arrive. Retire the input lease without changing the card's UI/launch path. + cleanups.push(transport.onClosed(res.sessionId, disposeInput)) + cleanups.push(transport.onRecycled(res.sessionId, disposeInput)) cleanups.push(transport.onData(res.sessionId, (d) => term.write(d))) cleanups.push( - transport.onExit(res.sessionId, () => + transport.onExit(res.sessionId, (code) => { + if (dead) return + disposeInput() term.write('\r\n\x1b[90m[session ended]\x1b[0m\r\n') - ) + if (code === 255) reportConnectionLost() + }) ) // The pty runs at the SMALLEST subscriber's grid; render exactly what it tells us and letterbox // the rest (the canvas node votes independently — the modal's smaller pane may shrink it). @@ -564,7 +601,6 @@ export function ModalTerminal({ cleanups.push( transport.onSize(res.sessionId, (size) => term.resize(size.cols, size.rows)) ) - term.onData((d) => sessionId && transport.write(sessionId, d)) // DELIBERATELY omitted vs. TerminalNode: no flow-control pause (transport.setFlow) and no // onResync handler. The pty's pacing/backpressure comes from the canvas node's client — the // modal is a transient, always-on-top second view and never drives the shared session's flow. @@ -628,7 +664,7 @@ export function ModalTerminal({ transportRef.current = null } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodeId]) + }, [nodeId, ownerProjectId, sshScopeId, attachRevision]) // Live-apply the appearance settings, mirroring the canvas node's effect (see TerminalNode) — // without this the modal kept whatever it was built with, so changing the font or the theme with @@ -669,7 +705,7 @@ export function ModalTerminal({ let paths: string[] if (spawn.sshRemoteTmux) { // Uploads go over the master this card's PTY runs on — its scope, not the project's. - const projectId = nodeUploadScope(spawn.ssh) + const projectId = nodeUploadScope(spawn.ssh, ownerProjectId) setUploading(true) try { paths = await droppedPaths(files, { sshRemoteTmux: true, projectId }) diff --git a/src/renderer/components/settings/sections/PairingNetworkRow.test.tsx b/src/renderer/components/settings/sections/PairingNetworkRow.test.tsx new file mode 100644 index 0000000000..22ef53994b --- /dev/null +++ b/src/renderer/components/settings/sections/PairingNetworkRow.test.tsx @@ -0,0 +1,251 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { DEFAULT_SETTINGS } from '@shared/types' +import { useSettings } from '@renderer/state/settings' +import { PhoneSection } from './PhoneSection' +import { SettingsSearchContext } from '../context' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true +vi.mock('qrcode', () => ({ toDataURL: async (payload: string) => `data:image/png;base64,${payload}` })) + +let root: Root +let container: HTMLElement +let networks = [{ interfaceName: 'wlan0', address: '192.168.1.42' }, { interfaceName: 'wg0', address: '10.7.0.2' }] +let persisted = '' +let save: ReturnType +let start: ReturnType +let stop: ReturnType +let list: ReturnType +let done: (result: { ok: boolean }) => void + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} + +async function mount(active = true, query = ''): Promise { + await act(async () => root.render()) +} +function button(label: string): HTMLButtonElement { + const found = [...container.querySelectorAll('button')].find((candidate) => candidate.textContent?.trim() === label) + expect(found, label).toBeTruthy() + return found! +} +function select(): HTMLSelectElement { return container.querySelector('#phone-pairing-network')! } +async function choose(value: string): Promise { + await act(async () => { + select().value = value + select().dispatchEvent(new Event('change', { bubbles: true })) + }) +} +const qr = (): HTMLImageElement | null => container.querySelector('img[alt="Pairing QR code"]') + +beforeEach(() => { + networks = [{ interfaceName: 'wlan0', address: '192.168.1.42' }, { interfaceName: 'wg0', address: '10.7.0.2' }] + persisted = '' + useSettings.setState({ settings: { ...DEFAULT_SETTINGS }, hydrated: true }) + save = vi.fn(async (settings: typeof DEFAULT_SETTINGS) => { persisted = settings.phonePairingInterface }) + start = vi.fn(async () => ({ payload: JSON.stringify({ v: 1, host: persisted || '192.168.1.42' }), sshOpen: true, relayPlan: 'off' })) + stop = vi.fn(async () => undefined) + list = vi.fn(async () => networks) + window.nodeTerminal = { + pairing: { listNetworks: list, start, stop, onDone: (callback: typeof done) => { done = callback; return () => {} }, + probeSsh: async () => true, listDevices: async () => [], revokeDevice: vi.fn(), openRemoteLoginSettings: vi.fn() }, + settings: { save }, remoteHost: { setPhoneAccess: vi.fn() }, shell: { openExternal: vi.fn() } + } as unknown as typeof window.nodeTerminal + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.restoreAllMocks() +}) + +describe('Settings Phone pairing network', () => { + it('shows Automatic and real adapter names/current addresses without claiming phone reachability', async () => { + await mount() + expect(select().value).toBe('') + expect([...select().options].map((option) => option.text)).toEqual(['Automatic', 'wlan0 — 192.168.1.42', 'wg0 — 10.7.0.2']) + expect(container.textContent).toContain('not a test of whether your phone can reach them') + }) + + it('loads and saves the visible network row reached through Settings search without selecting Phone navigation', async () => { + await mount(false, 'network') + expect(select()).not.toBeNull() + expect(select().disabled).toBe(false) + expect(list).toHaveBeenCalledTimes(1) + await choose('wg0') + expect(persisted).toBe('wg0') + expect(start).not.toHaveBeenCalled() + expect(container.textContent).toContain('Pairing network') + }) + + it('retires the owned pairing listener when Settings search hides Pair phone while its parent hook stays mounted', async () => { + await mount(false, 'network') + await act(async () => button('Start pairing').click()) + expect(qr()).not.toBeNull() + expect(start).toHaveBeenCalledTimes(1) + await mount(false, 'appearance') + expect(select()).toBeNull() + expect(stop).toHaveBeenCalledTimes(1) + await act(async () => done({ ok: true })) + await mount(false, 'network') + expect(qr()).toBeNull() + expect(button('Start pairing')).toBeTruthy() + expect(start).toHaveBeenCalledTimes(1) + }) + + it('saves the interface name while idle, without starting pairing', async () => { + await mount() + await choose('wg0') + expect(persisted).toBe('wg0') + expect(useSettings.getState().settings.phonePairingInterface).toBe('wg0') + expect(start).not.toHaveBeenCalled() + expect(stop).not.toHaveBeenCalled() + }) + + it('preserves an unavailable saved choice instead of displaying or saving Automatic', async () => { + useSettings.setState({ settings: { ...DEFAULT_SETTINGS, phonePairingInterface: 'en9' } }) + await mount() + expect(select().value).toBe('en9') + expect(select().selectedOptions[0].text).toBe('en9 — unavailable') + expect(container.textContent).toContain('No pairing address will be substituted') + expect(save).not.toHaveBeenCalled() + }) + + it('retires and hides a waiting QR before saving, then regenerates only after the core save acknowledgement', async () => { + await mount() + await act(async () => button('Start pairing').click()) + expect(qr()).not.toBeNull() + const held = deferred() + save.mockImplementationOnce(async (settings: typeof DEFAULT_SETTINGS) => { await held.promise; persisted = settings.phonePairingInterface }) + await choose('wg0') + expect(qr()).toBeNull() + expect(stop).toHaveBeenCalledTimes(1) + expect(start).toHaveBeenCalledTimes(1) + expect(persisted).toBe('') + await act(async () => held.resolve()) + expect(start).toHaveBeenCalledTimes(2) + expect(persisted).toBe('wg0') + expect(qr()?.src).toContain('wg0') + }) + + it('keeps the old QR hidden and surfaces a failed save without restarting', async () => { + await mount() + await act(async () => button('Start pairing').click()) + save.mockRejectedValueOnce(new Error('disk full')) + await choose('wg0') + expect(qr()).toBeNull() + expect(container.querySelector('[role="alert"]')?.textContent).toContain('Could not save the pairing network') + expect(start).toHaveBeenCalledTimes(1) + }) + + it('retries the same failed choice explicitly, keeping the old QR retired until the save is acknowledged', async () => { + await mount() + await act(async () => button('Start pairing').click()) + save.mockRejectedValueOnce(new Error('disk full')) + await choose('wg0') + expect(select().value).toBe('wg0') + expect(persisted).toBe('') + expect(qr()).toBeNull() + const held = deferred() + save.mockImplementationOnce(async (settings: typeof DEFAULT_SETTINGS) => { await held.promise; persisted = settings.phonePairingInterface }) + await act(async () => button('Retry save').click()) + expect(start).toHaveBeenCalledTimes(1) + expect(persisted).toBe('') + expect(qr()).toBeNull() + expect(select().disabled).toBe(true) + await act(async () => held.resolve()) + expect(persisted).toBe('wg0') + expect(start).toHaveBeenCalledTimes(2) + expect(qr()?.src).toContain('wg0') + expect(container.querySelector('[role="alert"]')).toBeNull() + expect([...container.querySelectorAll('button')].some((candidate) => candidate.textContent === 'Retry save')).toBe(false) + }) + + it('does not restart a failed idle save on retry, or expose Retry in an unsupported Server shell', async () => { + await mount() + save.mockRejectedValueOnce(new Error('disk full')) + await choose('wg0') + await act(async () => button('Retry save').click()) + expect(persisted).toBe('wg0') + expect(start).not.toHaveBeenCalled() + save.mockRejectedValueOnce(new Error('disk full')) + await choose('wlan0') + list.mockRejectedValueOnce(Object.assign(new Error('unsupported'), { code: 'E_UNSUPPORTED' })) + await act(async () => button('Refresh networks').click()) + expect(select()).toBeNull() + expect(container.textContent).not.toContain('Retry save') + }) + + it('admits only one save before React disables the dropdown, so duplicate events cannot restart another code', async () => { + await mount() + const held = deferred() + save.mockImplementationOnce(async (settings: typeof DEFAULT_SETTINGS) => { await held.promise; persisted = settings.phonePairingInterface }) + await act(async () => { + select().value = 'wg0' + select().dispatchEvent(new Event('change', { bubbles: true })) + select().value = 'wlan0' + select().dispatchEvent(new Event('change', { bubbles: true })) + }) + expect(save).toHaveBeenCalledTimes(1) + expect(useSettings.getState().settings.phonePairingInterface).toBe('wg0') + await act(async () => held.resolve()) + expect(persisted).toBe('wg0') + }) + + it('does not regenerate after Settings leaves during the save, and the row is usable when it returns', async () => { + await mount() + await act(async () => button('Start pairing').click()) + const held = deferred() + save.mockImplementationOnce(async () => held.promise) + await choose('wg0') + await mount(false) + await act(async () => held.resolve()) + expect(start).toHaveBeenCalledTimes(1) + await mount(true) + expect(select().disabled).toBe(false) + }) + + it('rechecks DHCP without changing the persisted adapter name or starting an idle code', async () => { + useSettings.setState({ settings: { ...DEFAULT_SETTINGS, phonePairingInterface: 'wlan0' } }) + await mount() + networks = [{ interfaceName: 'wlan0', address: '192.168.1.77' }] + await act(async () => button('Refresh networks').click()) + expect(select().selectedOptions[0].text).toBe('wlan0 — 192.168.1.77') + expect(save).not.toHaveBeenCalled() + expect(start).not.toHaveBeenCalled() + }) + + it('shows network read failure and permits Refresh to recover without resetting the selection', async () => { + list.mockRejectedValueOnce(new Error('OS read failed')) + useSettings.setState({ settings: { ...DEFAULT_SETTINGS, phonePairingInterface: 'wg0' } }) + await mount() + expect(container.querySelector('[role="alert"]')?.textContent).toContain('Could not read') + await act(async () => button('Refresh networks').click()) + expect(select().value).toBe('wg0') + expect(container.querySelector('[role="alert"]')).toBeNull() + }) + + it('hides the deliberate unsupported selector in the browser Server shell', async () => { + list.mockRejectedValueOnce(Object.assign(new Error('unsupported'), { code: 'E_UNSUPPORTED' })) + await mount() + expect(select()).toBeNull() + expect(container.textContent).not.toContain('Pairing network') + }) + + it('changing network after a completed scan saves it but does not restart pairing', async () => { + await mount() + await act(async () => button('Start pairing').click()) + await act(async () => done({ ok: true })) + await choose('wg0') + expect(start).toHaveBeenCalledTimes(1) + expect(container.textContent).toMatch(/Paired/) + }) +}) diff --git a/src/renderer/components/settings/sections/PairingNetworkRow.tsx b/src/renderer/components/settings/sections/PairingNetworkRow.tsx new file mode 100644 index 0000000000..6b55423578 --- /dev/null +++ b/src/renderer/components/settings/sections/PairingNetworkRow.tsx @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from 'react' +import type { PairingNetworkChoice } from '@shared/pairing-network' +import { useSettings } from '@renderer/state/settings' +import { Select } from '@renderer/ui/Select' +import { Button } from '@renderer/ui/Button' + +/** This preference belongs to the computer, and follows an adapter rather than a DHCP address. */ +export function PairingNetworkRow({ + isActive, pairingBusy, waiting, stopPairing, restartPairing +}: { + isActive: boolean + pairingBusy: boolean + waiting: boolean + stopPairing: () => void + restartPairing: () => Promise +}): React.JSX.Element | null { + const selected = useSettings((state) => state.settings.phonePairingInterface) + const [choices, setChoices] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [unsupported, setUnsupported] = useState(false) + const [error, setError] = useState('') + const [retry, setRetry] = useState<{ interfaceName: string; restart: boolean } | null>(null) + const stop = useRef(stopPairing) + stop.current = stopPairing + const current = useRef({ active: isActive, generation: 0, saving: false }) + current.current.active = isActive + + const refresh = async (generation: number): Promise => { + setLoading(true) + try { + const next = await window.nodeTerminal.pairing.listNetworks() + if (!current.current.active || current.current.generation !== generation) return + setChoices(next) + setUnsupported(false) + setError('') + } catch (cause) { + if (!current.current.active || current.current.generation !== generation) return + if ((cause as { code?: string })?.code === 'E_UNSUPPORTED') setUnsupported(true) + else setError('Could not read the computer’s networks. Refresh to try again.') + } finally { + if (current.current.active && current.current.generation === generation) setLoading(false) + } + } + + useEffect(() => { + current.current.active = isActive + const generation = ++current.current.generation + current.current.saving = false + setSaving(false) + setRetry(null) + if (isActive) void refresh(generation) + return () => { + current.current.active = false + current.current.generation++ + // PhoneSection's hook can outlive this row (Settings search/section filtering). + // Its owner guard retires our hidden listener without stopping another pairing view. + stop.current() + } + }, [isActive]) + + const saveChoice = async (interfaceName: string, restart: boolean): Promise => { + if (current.current.saving || pairingBusy || !current.current.active) return + const generation = ++current.current.generation + current.current.saving = true + setSaving(true) + setRetry(null) + setError('') + // An old QR is tied to the prior address: hide/retire it before waiting for the save. + if (restart) stopPairing() + useSettings.getState().update({ phonePairingInterface: interfaceName }) + try { + await useSettings.getState().flush() + if (!current.current.active || current.current.generation !== generation) return + if (restart) await restartPairing() + if (current.current.active && current.current.generation === generation) await refresh(generation) + } catch { + if (current.current.active && current.current.generation === generation) { + setError('Could not save the pairing network. No new code was started. Retry the save or choose another network.') + setRetry({ interfaceName, restart }) + } + } finally { + if (current.current.active && current.current.generation === generation) { + current.current.saving = false + setSaving(false) + } + } + } + + // The browser Server shell deliberately does not implement host pairing/adapter control. + if (unsupported) return null + const unavailable = !!selected && !choices.some((choice) => choice.interfaceName === selected) + return ( +
+ +
+ + + {retry ? : null} +
+

Automatic prefers a LAN adapter. Select your VPN or another adapter when needed. These are current addresses, not a test of whether your phone can reach them.

+ {unavailable && !loading ?

The selected network has no current IPv4 address. No pairing address will be substituted; reconnect it or choose another network.

: null} + {error ?

{error}

: null} +
+ ) +} diff --git a/src/renderer/components/settings/sections/PhoneSection.revoke.test.tsx b/src/renderer/components/settings/sections/PhoneSection.revoke.test.tsx index c9622e9b7f..f32c5b155e 100644 --- a/src/renderer/components/settings/sections/PhoneSection.revoke.test.tsx +++ b/src/renderer/components/settings/sections/PhoneSection.revoke.test.tsx @@ -1,8 +1,8 @@ // @vitest-environment jsdom // -// Settings → Phone, the Revoke button: which of the three revoke outcomes the user is TOLD about, -// and in which voice. -// 'skipped' → nothing. It is a normal state: a free-tier desktop holds no entitlement to revoke +// Settings → Phone, the Revoke button: independent Pro and remote-access receipts. +// Pro outcomes: +// 'skipped' → no Pro claim. It is a normal state: a free-tier desktop holds no entitlement to revoke // with, or there was no device left to name. Warning there would tell a free user // their phone's Pro is stuck when it never had any of ours. (A phone paired before // we recorded its relay id is NOT this case — the revoke falls back to our own @@ -10,7 +10,8 @@ // 'ok' → a receipt, not silence: the phone keeps the pass it already holds for up to 7 // days, and users who were told nothing filed that as "removal didn't work". // 'failed' → a warning that does not prescribe waiting, because a 403 never clears by waiting. -// Only a leg that actually failed may warn, and a failed one always must. +// Relay access has its own receipt: unknown/absent association must warn, even if local removal +// succeeds. Another pairing may retain it; only a confirmed unpin + closure is a clean receipt. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' @@ -45,6 +46,7 @@ function stubBridge(result: DeviceRevokeResult | Error): void { }) ;(window as unknown as { nodeTerminal: unknown }).nodeTerminal = { pairing: { + listNetworks: vi.fn(async () => []), start: vi.fn(), stop: vi.fn(async () => undefined), onDone: vi.fn(() => () => undefined), @@ -102,8 +104,8 @@ function mount(): void { } describe('PhoneSection revoke feedback', () => { - it('says nothing when the server leg was skipped — there was nothing of ours to revoke', async () => { - stubBridge({ local: true, server: 'skipped' }) + it('does not claim Pro revocation on a free desktop, while confirming its successful relay removal', async () => { + stubBridge({ local: true, server: 'skipped', relay: 'ok' }) mount() await act(async () => undefined) // the mount-time listDevices @@ -115,10 +117,12 @@ describe('PhoneSection revoke feedback', () => { // ends in 7 days. An implementation that prints the receipt for every non-failure passes the // two assertions above. expect(text).not.toMatch(/7 days/i) + expect(text).toMatch(/remote access was revoked/i) + expect(text).toMatch(/open remote sessions were closed/i) }) it('says WHEN the phone actually loses Pro on a clean revoke — removal is not instant', async () => { - stubBridge({ local: true, server: 'ok' }) + stubBridge({ local: true, server: 'ok', relay: 'ok' }) mount() await act(async () => undefined) @@ -183,6 +187,93 @@ describe('PhoneSection revoke feedback', () => { expect(text).not.toMatch(/Removed “/) }) + // revocation.ts: a relay key that could not be unpinned may let the phone back in with no + // approval code, so "Removed" is exactly what must not be said. Main keeps the device listed. + it('says the phone’s remote access is still there when its relay key could not be unpinned', async () => { + stubBridge({ local: false, server: 'skipped', relay: 'unpin-failed' }) + mount() + await act(async () => undefined) + + const text = await revokeFlow() + expect(text).toMatch(/remote access away/i) + expect(text).toMatch(/still listed — try again/i) + expect(text).not.toMatch(/Removed “/) + expect(text).toContain('Enes’ iPhone') // the row is still there to retry from + }) + + it('warns that a relay session may still be open, and keeps the Pro receipt beside it', async () => { + stubBridge({ local: true, server: 'ok', relay: 'cut-unconfirmed' }) + mount() + await act(async () => undefined) + + const text = await revokeFlow() + expect(text).toMatch(/could not be confirmed closed/i) + expect(text).toMatch(/quit and reopen nodeterm/i) + expect(text).toMatch(/within 7 days/i) + expect(text).not.toMatch(/try again/i) + }) + + it('confirms the completed relay leg beside the independent Pro expiry receipt', async () => { + stubBridge({ local: true, server: 'ok', relay: 'ok' }) + mount() + await act(async () => undefined) + + const text = await revokeFlow() + expect(text).toMatch(/Removed “Enes’ iPhone”\./) + expect(text).toMatch(/remote access was revoked and its open remote sessions were closed/i) + expect(text).toMatch(/Its Pro ends.*within 7 days/) + expect(text).not.toMatch(/may still connect|could not be confirmed|try again/i) + }) + + it.each(['unconfirmed', undefined] as const)( + 'warns after removing the last row when relay revocation is %s, including an older main process', + async (relay) => { + stubBridge({ local: true, server: 'skipped', ...(relay ? { relay } : {}) }) + mount() + await act(async () => undefined) + + const text = await revokeFlow() + expect(text).toContain('No devices paired yet') + expect(text).toMatch(/pairing and any SSH key it installed/i) + expect(text).toMatch(/remote access could not be confirmed revoked/i) + expect(text).toMatch(/may still connect remotely/i) + expect(text).not.toMatch(/remote access was revoked|remote sessions were closed|7 days/i) + } + ) + + it('keeps unknown relay access and confirmed Pro expiry separate', async () => { + stubBridge({ local: true, server: 'ok', relay: 'unconfirmed' }) + mount() + await act(async () => undefined) + + const text = await revokeFlow() + expect(text).toMatch(/may still connect remotely/i) + expect(text).toMatch(/Its Pro ends.*within 7 days/) + expect(text).not.toMatch(/remote access was revoked/i) + }) + + it('explains remote access retained by another pairing without calling it a failure or confirmed revoke', async () => { + stubBridge({ local: true, server: 'skipped', relay: 'retained' }) + mount() + await act(async () => undefined) + + const text = await revokeFlow() + expect(text).toMatch(/another pairing still allows its remote access and existing remote sessions/i) + expect(text).not.toMatch(/remote access was revoked|try again|7 days/i) + }) + + it('does not promise loss of every connection before an unknown legacy revoke', async () => { + stubBridge({ local: true, server: 'skipped', relay: 'unconfirmed' }) + mount() + await act(async () => undefined) + await act(async () => button('Revoke').click()) + + const text = document.body.textContent ?? '' + expect(text).toMatch(/removes its pairing and any SSH key installed by it/i) + expect(text).toMatch(/Remote access may remain.*remote identity is unknown/i) + expect(text).not.toMatch(/will no longer be able to connect/i) + }) + it('warns when the call itself never answered', async () => { stubBridge(new Error('E_UNSUPPORTED')) mount() diff --git a/src/renderer/components/settings/sections/PhoneSection.tsx b/src/renderer/components/settings/sections/PhoneSection.tsx index b71440b783..d51ce11c40 100644 --- a/src/renderer/components/settings/sections/PhoneSection.tsx +++ b/src/renderer/components/settings/sections/PhoneSection.tsx @@ -7,7 +7,7 @@ import { Button } from '@renderer/ui/Button' import { Switch } from '@renderer/ui/Switch' import { useSettings } from '@renderer/state/settings' import { usePhonePairing } from '../usePhonePairing' -import { mobileStoreLinks } from '@renderer/lib/links' +import { ANDROID_APP_LABEL, ANDROID_APP_URL, ANDROID_APP_PUBLISHED, mobileStoreLinks } from '@renderer/lib/links' import { hostOsFromNavigator, sshServerCopy } from '@shared/ssh-server' import { pairingEndedMessage, @@ -16,6 +16,7 @@ import { relayOnlyExplanation } from '@shared/pairing-gate' import { thisMachine } from '../../../lib/machineName' +import { PairingNetworkRow } from './PairingNetworkRow' import { isBrowserRuntime } from '@renderer/bridge/runtime' import { PushWebhookPanel } from './PushWebhookPanel' @@ -26,7 +27,7 @@ const ROWS = { }, pair: { title: 'Pair phone', - keywords: ['phone', 'pair', 'qr', 'ios', 'android', 'mobile', 'ssh', 'scan', 'nodeterm'] + keywords: ['phone', 'pair', 'qr', 'ios', 'android', 'mobile', 'ssh', 'scan', 'nodeterm', 'network', 'adapter', 'vpn', 'lan', 'wireguard'] }, devices: { title: 'Paired devices', @@ -143,9 +144,33 @@ export function PhoneSection({ isActive }: { isActive: boolean }): React.JSX.Ele // Additive, not exclusive: both legs can fail at once (an unwritable ~/.ssh while offline), // and being told only half of that leaves the other half to be discovered by accident. const notes: string[] = [] - if (!result.local) { + if (result.relay === 'unpin-failed') { + // The phone's relay key may still be pinned, which would let it back in with no approval + // code, so main kept the device listed (`local` is false) for this retry (A07-revoke). + notes.push( + `Couldn’t take “${device.name}”’s remote access away on this machine, so it is still listed — try again.` + ) + } else if (!result.local) { notes.push(`Couldn’t remove “${device.name}” from this machine — try again.`) } + if (result.relay === 'cut-unconfirmed') { + // Unpinned, so it cannot come back; a session whose close failed may still be half open. + // Quitting nodeterm closes every relay connection it has. + notes.push( + `A remote session “${device.name}” had open could not be confirmed closed — quit and reopen nodeterm to be sure it has ended.` + ) + } + if (result.local && (result.relay === 'unconfirmed' || result.relay === undefined)) { + // An older main process also omits this leg. Missing proof must not become a receipt. + notes.push( + `Removed “${device.name}”’s pairing and any SSH key it installed, but its remote access could not be confirmed revoked. It may still connect remotely.` + ) + } + if (result.local && result.relay === 'retained') { + notes.push( + `Removed this pairing for “${device.name}”, but another pairing still allows its remote access and existing remote sessions.` + ) + } if (result.server === 'failed') { // Deliberately not "pair it and remove it again": that used to be the whole advice, and it // is wrong twice over — a 403 will never clear however long you wait, and pairing RESTORES @@ -159,12 +184,19 @@ export function PhoneSection({ isActive }: { isActive: boolean }): React.JSX.Ele ) } if (notes.length) { + // A relay note beside a removal that did go through must not swallow the Pro receipt below. + if (result.local && result.server === 'ok') { + notes.push('Its Pro ends when the pass it already holds expires — within 7 days.') + } setRevokeNote({ text: notes.join(' '), warn: true }) - } else if (result.server === 'ok') { + } else if (result.local && (result.relay === 'ok' || result.server === 'ok')) { // Not instant, and we say so. The phone holds a signed entitlement minted for up to seven // days; revoking the row stops the NEXT one, it cannot reach into the phone. setRevokeNote({ - text: `Removed “${device.name}”. Its Pro ends when the pass it already holds expires — within 7 days.`, + text: + `Removed “${device.name}”.` + + (result.relay === 'ok' ? ' Its remote access was revoked and its open remote sessions were closed.' : '') + + (result.server === 'ok' ? ' Its Pro ends when the pass it already holds expires — within 7 days.' : ''), warn: false }) } @@ -180,7 +212,7 @@ export function PhoneSection({ isActive }: { isActive: boolean }): React.JSX.Ele @@ -209,8 +241,9 @@ export function PhoneSection({ isActive }: { isActive: boolean }): React.JSX.Ele

Pair phone

- Pair the nodeterm mobile app: scan this QR with your phone. Your phone generates its own - key on-device — nothing secret leaves this machine except a single-use pairing token. + Pair the nodeterm phone app (iPhone or Android): scan this QR from inside the app. Your + phone generates its own key on-device — nothing secret leaves this machine except a + single-use pairing token.

Don't have the app yet?{' '} @@ -225,8 +258,23 @@ export function PhoneSection({ isActive }: { isActive: boolean }): React.JSX.Ele ))} + {!ANDROID_APP_PUBLISHED && ( + <> + {' · '} + + + )}

+ {/* This row mounts only while Pair phone is visible, including global-search results. */} + + {phase === 'idle' || phase === 'timeout' ? (
{phase === 'timeout' ? ( @@ -293,12 +341,12 @@ export function PhoneSection({ isActive }: { isActive: boolean }): React.JSX.Ele > {qrForm === 'url' ? 'Show the in-app code instead' - : 'Scan with the iPhone Camera app instead'} + : "Scan with the phone's Camera app instead"} {qrForm === 'url' ? (

- Point the iPhone's own Camera at this and tap the nodeterm banner. - Needs a recent version of the iOS app — if your phone doesn't + Point the phone's own Camera at this and tap the nodeterm link. + Needs a recent version of the nodeterm app — if your phone doesn't recognise it, switch back and scan from inside nodeterm. Same code either way; switching doesn't restart pairing.

@@ -417,7 +465,7 @@ export function PhoneSection({ isActive }: { isActive: boolean }): React.JSX.Ele // Both legs, and the timing of the one that is not instant. "If" rather than a flat // claim: a free-tier desktop has no Pro of ours on that phone to take back, and this // dialog cannot tell — the server leg reports that only after the fact ('skipped'). - message={`Revoke “${pendingRevoke.name}”? Its key is removed from this machine and it will no longer be able to connect. If its Pro comes from ${thisMachine()}’s license, that is revoked too — the phone loses Pro within 7 days.`} + message={`Revoke “${pendingRevoke.name}”? This removes its pairing and any SSH key installed by it. Remote access may remain if another pairing allows it or this pairing’s remote identity is unknown. If its Pro comes from ${thisMachine()}’s license, that is revoked too — the phone loses Pro within 7 days.`} confirmLabel="Revoke" onConfirm={() => void revokeDevice(pendingRevoke)} onCancel={() => setPendingRevoke(null)} diff --git a/src/renderer/components/settings/sections/PhoneSection.windows.test.tsx b/src/renderer/components/settings/sections/PhoneSection.windows.test.tsx index 8824f26704..eb4844a60a 100644 --- a/src/renderer/components/settings/sections/PhoneSection.windows.test.tsx +++ b/src/renderer/components/settings/sections/PhoneSection.windows.test.tsx @@ -21,6 +21,7 @@ let fireDone: Done function stubBridge(start: Record): void { ;(window as unknown as { nodeTerminal: unknown }).nodeTerminal = { pairing: { + listNetworks: vi.fn(async () => []), start: vi.fn(async () => ({ payload: '{"v":1}', sshOpen: false, ...start })), stop: vi.fn(async () => undefined), onDone: vi.fn((cb: Done) => { @@ -35,6 +36,7 @@ function stubBridge(start: Record): void { webhookStatus: vi.fn(async () => ({ ok: true, value: null })), webhookEndpoint: vi.fn(async () => 'https://api.test') }, + settings: { save: vi.fn(async () => undefined) }, remoteHost: { setPhoneAccess: vi.fn() }, shell: { openExternal: vi.fn() } } diff --git a/src/renderer/components/settings/usePhonePairing.test.tsx b/src/renderer/components/settings/usePhonePairing.test.tsx new file mode 100644 index 0000000000..d37521aa25 --- /dev/null +++ b/src/renderer/components/settings/usePhonePairing.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { DEFAULT_SETTINGS } from '@shared/types' +import { useSettings } from '@renderer/state/settings' +import { usePhonePairing } from './usePhonePairing' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true +vi.mock('qrcode', () => ({ toDataURL: async () => 'data:image/png;base64,QR' })) +let root: Root +let host: HTMLElement +let api!: ReturnType +let save: ReturnType +let start: ReturnType +let stop: ReturnType +let callbacks: Array<(result: { ok: boolean }) => void> +function Harness(): React.JSX.Element { api = usePhonePairing(); return

{api.phase}:{api.busy ? 'busy' : 'idle'}:{api.error}

} +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((yes) => { resolve = yes }) + return { resolve, promise } +} +const response = { payload: '{"v":1,"host":"192.168.1.42"}', sshOpen: true, relayPlan: 'off' } +beforeEach(async () => { + useSettings.setState({ settings: { ...DEFAULT_SETTINGS } }) + save = vi.fn(async () => undefined) + start = vi.fn(async () => response) + stop = vi.fn(async () => undefined) + callbacks = [] + window.nodeTerminal = { settings: { save }, pairing: { start, stop, probeSsh: async () => true, + onDone: (callback: typeof callbacks[number]) => { callbacks.push(callback); return () => {} } } } as unknown as typeof window.nodeTerminal + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) + await act(async () => root.render()) +}) +afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks() }) + +describe('shared pairing listener lifecycle', () => { + it('waits for settings persistence before starting Settings or quick-pair', async () => { + const held = deferred() + save.mockReturnValueOnce(held.promise) + let pending!: Promise + await act(async () => { pending = api.start() }) + expect(start).not.toHaveBeenCalled() + await act(async () => held.resolve()) + await pending + expect(start).toHaveBeenCalledTimes(1) + expect(api.phase).toBe('waiting') + }) + + it('never starts a listener after the view closes during settings persistence', async () => { + const held = deferred() + save.mockReturnValueOnce(held.promise) + let pending!: Promise + await act(async () => { pending = api.start() }) + await act(async () => root.render(null)) + await act(async () => held.resolve()) + await pending + expect(start).not.toHaveBeenCalled() + }) + + it('retires a late native start reply after unmount rather than leaving a headless pairing listener', async () => { + const held = deferred() + start.mockReturnValueOnce(held.promise) + let pending!: Promise + await act(async () => { pending = api.start() }) + expect(start).toHaveBeenCalledTimes(1) + await act(async () => root.render(null)) + await act(async () => held.resolve(response)) + await pending + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('orders overlapping starts and discards the retired reply before its replacement is invoked', async () => { + const held = deferred() + start.mockReturnValueOnce(held.promise) + let first!: Promise + let second!: Promise + await act(async () => { first = api.start() }) + await act(async () => { second = api.start() }) + expect(start).toHaveBeenCalledTimes(1) + await act(async () => held.resolve(response)) + await Promise.all([first, second]) + expect(stop).toHaveBeenCalledTimes(1) + expect(start).toHaveBeenCalledTimes(2) + expect(stop.mock.invocationCallOrder[0]).toBeLessThan(start.mock.invocationCallOrder[1]) + expect(api.phase).toBe('waiting') + }) + + it('does not publish a retired first reply while its replacement waits for a second save', async () => { + const native = deferred() + start.mockReturnValueOnce(native.promise) + let first!: Promise + let second!: Promise + await act(async () => { first = api.start() }) + const saved = deferred() + save.mockReturnValueOnce(saved.promise) + await act(async () => { second = api.start() }) + await act(async () => native.resolve(response)) + await first + expect(save).toHaveBeenCalledTimes(2) + expect(start).toHaveBeenCalledTimes(1) + expect(api.phase).toBe('idle') + expect(api.qr).toBe('') + expect(api.busy).toBe(true) + await act(async () => saved.resolve()) + await second + expect(start).toHaveBeenCalledTimes(2) + expect(api.phase).toBe('waiting') + }) + + it('an old view cannot close a newer view’s listener or consume its completion event', async () => { + let old!: ReturnType + function Both({ newer }: { newer: boolean }): React.JSX.Element { + old = usePhonePairing() + return newer ? :

old

+ } + await act(async () => root.render()) + await act(async () => old.start()) + await act(async () => root.render()) + await act(async () => api.start()) + const stops = stop.mock.calls.length + await act(async () => old.stop()) + expect(stop).toHaveBeenCalledTimes(stops) + await act(async () => callbacks.forEach((callback) => callback({ ok: true }))) + expect(api.phase).toBe('paired') + expect(old.phase).toBe('idle') + }) + + it('keeps a settings failure visible and never starts a code without the saved choice', async () => { + save.mockRejectedValueOnce(new Error('cannot save settings')) + await act(async () => api.start()) + expect(start).not.toHaveBeenCalled() + expect(api.error).toBe('cannot save settings') + expect(api.busy).toBe(false) + }) +}) diff --git a/src/renderer/components/settings/usePhonePairing.ts b/src/renderer/components/settings/usePhonePairing.ts index b611fe7519..6ca891d844 100644 --- a/src/renderer/components/settings/usePhonePairing.ts +++ b/src/renderer/components/settings/usePhonePairing.ts @@ -2,12 +2,23 @@ import { useEffect, useRef, useState } from 'react' import { toDataURL } from 'qrcode' import { DEFAULT_PAIR_QR_FORM, encodePairQr, type PairQrForm } from '@shared/pair-qr' import type { WindowsKeyFileHint } from '@shared/pairing-gate' +import { useSettings } from '@renderer/state/settings' export type PairingPhase = 'idle' | 'waiting' | 'paired' | 'timeout' /** How often the Remote Login warning re-probes sshd while it is showing. */ const SSH_RECHECK_MS = 2000 +// Both transient views share one main-process listener. Queue its commands and remember the +// owning view, so a late start/unmount from Settings cannot stop a newer quick-pair listener. +let pairingCommands: Promise = Promise.resolve() +let pairingOwner: symbol | null = null +function pairingCommand(operation: () => Promise): Promise { + const run = pairingCommands.then(operation, operation) + pairingCommands = run.then(() => undefined, () => undefined) + return run +} + /** * The phone-pairing state machine, shared by Settings → Phone and the quick-pair popover: * start/stop, the QR data URL, the completion event, and the live Remote-Login (sshd) re-probe @@ -62,6 +73,8 @@ export function usePhonePairing(onPaired?: () => void): { const [busy, setBusy] = useState(false) // Track whether a pairing listener is currently running so unmount can stop it. const runningRef = useRef(false) + const owner = useRef(Symbol('phone pairing')) + const lifecycle = useRef({ mounted: true, generation: 0 }) // Live re-check while the Remote Login warning is visible: the initial probe runs once at // pairing start, so without this the warning could never clear — the user enables Remote Login @@ -91,33 +104,62 @@ export function usePhonePairing(onPaired?: () => void): { }, [phase, sshOpen, sshKey]) const start = async (): Promise => { + if (!lifecycle.current.mounted) return + const generation = ++lifecycle.current.generation + const current = (): boolean => lifecycle.current.mounted && lifecycle.current.generation === generation setError('') setBusy(true) - try { - const { - payload: built, - sshOpen: open, - relayPlan: plan, - sshKey: key, - windowsKeyFile: keyFile - } = await window.nodeTerminal.pairing.start() - setRelayPlan(plan ?? null) - setSshKey(key !== false) - setWindowsKeyFile(keyFile) - setEnded(null) - // The image itself is rendered by the effect below, which also handles a later switch - // between the JSON and URL envelopes. - setPayload(built) - setSshOpen(open) - setSshHealed(false) - setRelayResult(null) - setPhase('waiting') - runningRef.current = true - } catch (err) { - setError((err as Error).message) - } finally { - setBusy(false) - } + setPayload('') + setQr('') + setPhase('idle') + await pairingCommand(async () => { + if (!current()) return + try { + if (pairingOwner) { + pairingOwner = null + runningRef.current = false + await window.nodeTerminal.pairing.stop() + } + await useSettings.getState().flush() + if (!current()) return + pairingOwner = owner.current + const { + payload: built, + sshOpen: open, + relayPlan: plan, + sshKey: key, + windowsKeyFile: keyFile + } = await window.nodeTerminal.pairing.start() + if (!current()) { + // Commands are serialized, so no replacement start can have overtaken this reply. + if (pairingOwner === owner.current) { + pairingOwner = null + await window.nodeTerminal.pairing.stop() + } + return + } + setRelayPlan(plan ?? null) + setSshKey(key !== false) + setWindowsKeyFile(keyFile) + setEnded(null) + // The image itself is rendered by the effect below, which also handles a later switch + // between the JSON and URL envelopes. + setPayload(built) + setSshOpen(open) + setSshHealed(false) + setRelayResult(null) + setPhase('waiting') + runningRef.current = true + } catch (err) { + if (pairingOwner === owner.current) { + pairingOwner = null + await window.nodeTerminal.pairing.stop().catch(() => {}) + } + if (current()) setError((err as Error).message) + } finally { + if (current()) setBusy(false) + } + }) } // Render the QR for whatever payload + envelope is current. Re-runs when the user switches @@ -143,10 +185,14 @@ export function usePhonePairing(onPaired?: () => void): { }, [payload, qrForm]) const stop = (): void => { - if (runningRef.current) { - runningRef.current = false - void window.nodeTerminal.pairing.stop() - } + lifecycle.current.generation++ + runningRef.current = false + void pairingCommand(async () => { + if (pairingOwner !== owner.current) return + pairingOwner = null + await window.nodeTerminal.pairing.stop() + }).catch(() => {}) + setBusy(false) setPhase('idle') setPayload('') setQr('') @@ -158,6 +204,10 @@ export function usePhonePairing(onPaired?: () => void): { onPairedRef.current = onPaired useEffect(() => { return window.nodeTerminal.pairing.onDone((result) => { + if (!lifecycle.current.mounted || pairingOwner !== owner.current) return + pairingOwner = null + lifecycle.current.generation++ + setBusy(false) runningRef.current = false setPayload('') setQr('') @@ -170,11 +220,16 @@ export function usePhonePairing(onPaired?: () => void): { // Stop any in-flight pairing when the owning view unmounts (closed / navigated away). useEffect(() => { + lifecycle.current.mounted = true return () => { - if (runningRef.current) { - runningRef.current = false - void window.nodeTerminal.pairing.stop() - } + lifecycle.current.mounted = false + lifecycle.current.generation++ + runningRef.current = false + void pairingCommand(async () => { + if (pairingOwner !== owner.current) return + pairingOwner = null + await window.nodeTerminal.pairing.stop() + }).catch(() => {}) } }, []) @@ -194,6 +249,6 @@ export function usePhonePairing(onPaired?: () => void): { busy, start, stop, - reset: () => setPhase('idle') + reset: stop } } diff --git a/src/renderer/lib/links.ts b/src/renderer/lib/links.ts index 06a8b4b9cf..271395218c 100644 --- a/src/renderer/lib/links.ts +++ b/src/renderer/lib/links.ts @@ -1,7 +1,23 @@ +import { REPO_URL } from './bugReport' + /** nodeterm mobile on the App Store (live since 1.0). Render it through `mobileStoreLinks()` — * `mobileStore.guard.test.ts` refuses a direct import anywhere else. */ export const IOS_APP_STORE_URL = 'https://apps.apple.com/app/nodeterm/id6790581233' +/** nodeterm for Android: SOURCE, not an installable. There is no store listing and no published + * APK yet (the only APK is an expiring CI run artifact behind a GitHub login), so the link opens + * the repo's `android/` folder, whose README says how to build it. Both call sites label it + * "(build from source)" for that reason — it sits beside an App Store link, and without the label + * it reads as a download. Once a signed release APK is published, point this at that release + * asset and drop the label. + * + * Derived from `REPO_URL` rather than spelled out, so the repository has one home. */ +export const ANDROID_APP_URL = `${REPO_URL}/tree/main/android` + +/** The visible label of that link, shared by Settings → Phone and the quick-pair popover so the + * two can never disagree about what it opens. */ +export const ANDROID_APP_LABEL = 'nodeterm for Android (build from source)' + /** * nodeterm mobile for Android on Google Play. The listing DOES NOT EXIST YET — this is the package * id the Android app ships under. Nothing renders it until `ANDROID_APP_PUBLISHED` is true, so no diff --git a/src/renderer/lib/sshReconnect.test.ts b/src/renderer/lib/sshReconnect.test.ts index 956d1bcbe0..3aacf99c38 100644 --- a/src/renderer/lib/sshReconnect.test.ts +++ b/src/renderer/lib/sshReconnect.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { SshReconnector, RECONNECT_DELAYS_MS, RESPAWN_REFUSE_MS } from './sshReconnect' +import { SshReconnector, RECONNECT_DELAYS_MS, RESPAWN_REFUSE_MS, subscribeSshReattach } from './sshReconnect' describe('SshReconnector', () => { beforeEach(() => { @@ -172,3 +172,79 @@ describe('SshReconnector', () => { expect(respawn).not.toHaveBeenCalled() }) }) + +describe('SSH reconnect view subscriptions', () => { + const unsubscribes: Array<() => void> = [] + let rec: SshReconnector + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + rec?.dispose() + unsubscribes.splice(0).forEach((off) => off()) + vi.useRealTimers() + }) + const watch = (scope: string, node: string) => { + const listener = vi.fn() + const off = subscribeSshReattach(scope, node, listener) + unsubscribes.push(off) + return { listener, off } + } + + it('notifies every matching view after success, without notifying another scope or node', async () => { + const first = watch('owner-host', 'n1') + const second = watch('owner-host', 'n1') + const otherScope = watch('other-host', 'n1') + const otherNode = watch('owner-host', 'n2') + rec = new SshReconnector({ connect: async () => true, respawn: vi.fn() }) + // Canvas and Modal can both report the same exit; the pending set coalesces them. + rec.reportDrop('owner-host', 'n1') + rec.reportDrop('owner-host', 'n1') + expect(first.listener).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(RECONNECT_DELAYS_MS[0]) + expect(first.listener).toHaveBeenCalledTimes(1) + expect(second.listener).toHaveBeenCalledTimes(1) + expect(otherScope.listener).not.toHaveBeenCalled() + expect(otherNode.listener).not.toHaveBeenCalled() + rec.onConnected('owner-host') + expect(first.listener).toHaveBeenCalledTimes(1) + }) + + it('keeps views untouched on failure and delivers an external successful reconnect only once', async () => { + const view = watch('p1', 'n1') + rec = new SshReconnector({ connect: async () => false, respawn: vi.fn() }) + rec.reportDrop('p1', 'n1') + for (const delay of RECONNECT_DELAYS_MS) await vi.advanceTimersByTimeAsync(delay) + expect(view.listener).not.toHaveBeenCalled() + rec.onConnected('p2') + expect(view.listener).not.toHaveBeenCalled() + rec.onConnected('p1') + rec.onConnected('p1') + expect(view.listener).toHaveBeenCalledTimes(1) + }) + + it('does not double-deliver when the connected event wins the connect-promise race', async () => { + let finish!: (value: boolean) => void + const view = watch('p1', 'n1') + rec = new SshReconnector({ + connect: () => new Promise((resolve) => { finish = resolve }), + respawn: vi.fn() + }) + rec.reportDrop('p1', 'n1') + await vi.advanceTimersByTimeAsync(RECONNECT_DELAYS_MS[0]) + rec.onConnected('p1') + finish(true) + await vi.advanceTimersByTimeAsync(0) + expect(view.listener).toHaveBeenCalledTimes(1) + }) + + it('retired subscriptions cannot remove a replacement view or receive later notifications', async () => { + const retired = watch('p1', 'n1') + retired.off() + const replacement = watch('p1', 'n1') + retired.off() // a late second cleanup must not delete the new scope's listeners + rec = new SshReconnector({ connect: async () => true, respawn: vi.fn() }) + rec.reportDrop('p1', 'n1') + await vi.advanceTimersByTimeAsync(RECONNECT_DELAYS_MS[0]) + expect(retired.listener).not.toHaveBeenCalled() + expect(replacement.listener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/lib/sshReconnect.ts b/src/renderer/lib/sshReconnect.ts index 76683740a9..5e7efde393 100644 --- a/src/renderer/lib/sshReconnect.ts +++ b/src/renderer/lib/sshReconnect.ts @@ -19,6 +19,36 @@ export const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000] * would hot-loop connect→respawn→die forever. */ export const RESPAWN_REFUSE_MS = 10_000 +// An open card can be the only mounted view of an offscreen project's terminal. Canvas's +// respawnNonce never reaches that view, so successful reconnects also notify these exact +// scope/node subscribers. This is renderer-only lifetime state, never persisted or replayed. +const reattachListeners = new Map void>>>() + +export function subscribeSshReattach(scopeId: string, nodeId: string, listener: () => void): () => void { + let nodes = reattachListeners.get(scopeId) + if (!nodes) reattachListeners.set(scopeId, nodes = new Map()) + let listeners = nodes.get(nodeId) + if (!listeners) nodes.set(nodeId, listeners = new Set()) + listeners.add(listener) + const scopeNodes = nodes + const nodeListeners = listeners + let subscribed = true + return () => { + if (!subscribed) return + subscribed = false + nodeListeners.delete(listener) + if (!nodeListeners.size) scopeNodes.delete(nodeId) + if (!scopeNodes.size) reattachListeners.delete(scopeId) + } +} + +function notifySshReattach(scopeId: string, nodeIds: string[]): void { + for (const nodeId of nodeIds) { + const listeners = reattachListeners.get(scopeId)?.get(nodeId) + if (listeners) for (const listener of [...listeners]) listener() + } +} + interface ProjectLoop { /** Nodes waiting for a respawn once the master is back. */ pending: Set @@ -138,6 +168,7 @@ export class SshReconnector { const nodeIds = [...loop.pending] const now = Date.now() for (const id of nodeIds) this.lastRespawn.set(id, now) + notifySshReattach(projectId, nodeIds) this.deps.respawn(projectId, nodeIds) } } diff --git a/src/renderer/nodes/TerminalNode.tsx b/src/renderer/nodes/TerminalNode.tsx index 82366c54b6..41af2b26d0 100644 --- a/src/renderer/nodes/TerminalNode.tsx +++ b/src/renderer/nodes/TerminalNode.tsx @@ -5,6 +5,8 @@ import { FIND_DECORATIONS } from '../lib/palette' import { ptyRefusal } from '@shared/pty-refusal' import { patchImeModeSwitch } from '../terminal/ime-mode-switch' +import { bindXtermInput } from '../terminal/xterm-input' +import { createDeferredBlur } from '../terminal/deferred-blur' import { installGlassCellBackgrounds, scheduleGlassCellAlpha, setGlassCellAlpha } from '../terminal/glass-cell-backgrounds' import { deliverRelayInitialLaunch } from '../terminal/relay-initial-launch' @@ -365,11 +367,15 @@ export const SLOW_REMOTE_SPAWN_NOTICE_MS = 1500 * the project IS that SSH endpoint, otherwise the project × endpoint host attachment — a remote * node living in a local canvas (or in an SSH project pointed at a different host). * - * A node only ever exists in the active project's React Flow, so the active project is its owner. + * Canvas nodes default to the active owner. A global-board card supplies its own project id. */ -export function sshConnectionScope(conn: SshConnection): string { - const { activeProjectId, getProject } = useProjects.getState() - return sshConnectionIdForProject(activeProjectId, conn, getProject(activeProjectId)?.ssh?.server) +export function sshConnectionScope( + conn: SshConnection, + ownerProjectId = useProjects.getState().activeProjectId +): string { + return sshConnectionIdForProject( + ownerProjectId, conn, useProjects.getState().getProject(ownerProjectId)?.ssh?.server + ) } /** @@ -378,8 +384,11 @@ export function sshConnectionScope(conn: SshConnection): string { * the terminal drop, the card modal's live viewer and the ⌘M composer's attach, so the three can * never upload one file to two different machines. */ -export function nodeUploadScope(ssh: SshConnection | undefined): string { - return ssh ? sshConnectionScope(ssh) : useProjects.getState().activeProjectId +export function nodeUploadScope( + ssh: SshConnection | undefined, + ownerProjectId = useProjects.getState().activeProjectId +): string { + return ssh ? sshConnectionScope(ssh, ownerProjectId) : ownerProjectId } /** @@ -461,7 +470,9 @@ export async function resolveSshRemote( * hosts — and the coalesced `tmux list-sessions` this shares with `create` would be two reads * instead of one. */ - early?: { nodeId: string; pty: Pick } + early?: { nodeId: string; pty: Pick }, + /** Global-board cards can belong to an inactive canvas; ordinary nodes use the active owner. */ + ownerProjectId = useProjects.getState().activeProjectId ): Promise< | { controlPath: string @@ -473,26 +484,23 @@ export async function resolveSshRemote( } | undefined > { - const activeProjectId = useProjects.getState().activeProjectId - // A relay tab's node belongs to another machine: never dial or wait for a master for it here - // (session/relay-ssh.ts). The caller treats undefined as "no master"; the relay spawn path does - // not call this at all — this is the second half. - if (!projectMayDialSsh(useProjects.getState().getProject(activeProjectId))) return undefined - const projectId = sshConnectionScope(conn) + // Relay-owned nodes must never dial a local master, including cards in inactive projects. + if (!projectMayDialSsh(useProjects.getState().getProject(ownerProjectId))) return undefined + const projectId = sshConnectionScope(conn, ownerProjectId) // A HOST ATTACHMENT dials for itself, HERE, because nothing else will. Canvas's active-project // effect pre-warms the attachments it can SEE in the stored canvas, but a node created at // runtime — the remote account-login retry drops one into whatever tab is active — never // appears in that pass, and would otherwise wait out the window under a scope no master exists // for and then sit offline forever. Idempotent and deduped, so the pre-warm and every node on // the machine collapse into one connect; the wait below is what actually blocks on it. - if (projectId !== activeProjectId) { + if (projectId !== ownerProjectId) { void connectHostAttachment( projectId, { conn, hostKey: sshHostKey(conn), remoteCwd: cwd, - ownerProjectId: activeProjectId + ownerProjectId }, (scopeId, c, remoteCwd) => window.nodeTerminal.sshProject.connect(scopeId, c, remoteCwd), (scopeId) => window.nodeTerminal.sshProject.disconnect(scopeId) @@ -1211,14 +1219,20 @@ export function isNodeWatched(nodeId: string): boolean { * modal (a second, equally real way of opening a session — the canvas visibility observer says * nothing about it). * - * Same park-surviving reason as `restartSubs`: no entry = nobody is mounted = nothing to wake. + * Same park-surviving reason as `restartSubs`: no entry means Canvas must use the guarded saved-project executor. */ const wakeSubs = new Map void>() /** Ask a node to resume its hibernated CLI. No-op if it is not mounted, or not hibernated (the * node re-reads the flag itself — this is a nudge, never an assertion). */ -export function wakeHibernatedNode(nodeId: string): void { - wakeSubs.get(nodeId)?.() +export function wakeHibernatedNode(nodeId: string, automatic = false): boolean { + if (automatic) { + const state = useAgentStatus.getState().byId[nodeId] + if (!shouldAutoWake(state?.hibernated, state?.paused)) return true + } + const trigger = wakeSubs.get(nodeId) + trigger?.() + return !!trigger } /** @@ -1433,6 +1447,7 @@ export function TerminalNode({ // ResizeObserver in the lifecycle effect. const rootRef = useRef(null) const termRef = useRef(null) + const deferredBlur = useMemo(() => createDeferredBlur(() => termRef.current), []) // Copy feedback: the `Copied` pill (fed by the OSC 52 handler below, through `copySubs`) and the // one-time "hold ⌥ to select" hint for a pane whose app captured the mouse. // The host is `bodyRef` (`.term-node__xterm`) and NOT the node body on purpose: the hover guard @@ -2682,6 +2697,9 @@ export function TerminalNode({ // what actually guarantees the terminal is back on a renderer that paints. } webgl = null + // Restore made the original context live again. Dispose only detaches its canvas; + // explicitly retire that captured context before the coordinator grants another. + loseWebglContexts(addonCanvas ? [addonCanvas] : null) verifyCleanDomState('context-restored') fullRepaint() webglHandle?.contextLost() @@ -3436,15 +3454,23 @@ export function TerminalNode({ e, isGlobalKanbanOpen() || isKanbanOpen(useProjects.getState().activeProjectId ?? '') ) - const action = terminalKeyAction(e, term.hasSelection(), ownsProjectJump, registryOwns) + const action = terminalKeyAction( + e, + term.hasSelection(), + ownsProjectJump, + registryOwns, + undefined, + { isDesktop: !isBrowserRuntime(), isMac } + ) if (action === 'pass') return true // 'bubble': the window dispatcher owns this chord (an allowInTerminal registry command). // Return false so xterm skips its own keymap — which would consume e.g. Ctrl+Shift+Arrow // into a CSI write and cancel the event — and DO NOT preventDefault: the dispatcher bails // on defaultPrevented events, so a prevented bubble would kill the very dispatch this // exists to reach. - // 'native': same mechanics, different owner — the PLATFORM's paste (Windows Ctrl+V, issue - // #562). xterm would map it to \x16 and cancel the keydown, which suppresses both + // 'native': same mechanics, different owner — the Desktop Quit role or PLATFORM paste. + // Quit stays live under either policy; Ctrl+Q otherwise becomes XON and is cancelled. + // Windows Ctrl+V (issue #562) maps to \x16 and cancels the keydown, which suppresses both // Chromium's paste command and the Edit menu's Ctrl+V accelerator; leaving the event // untouched lets the ordinary `paste` event reach xterm's textarea, exactly as ⌘V does on // macOS (bracketed-paste framing included). @@ -3711,6 +3737,25 @@ export function TerminalNode({ let offData: (() => void) | undefined if (onDisposed()) return sessionId = sid + // Before any seed/queued output is parsed, bind one responder for this actual session. + // The shared cleanups survive Canvas park/adoption and release on exit/final disposal. + const disposeInput = bindXtermInput(term, api.pty, sid, (input) => { + if (life.dead) return + // Lone Esc / Ctrl-C while the agent works: Claude Code fires no interrupt hook. + if (showStatus && (input === '\x1b' || input === '\x03')) inferInterruptAfterSettle(id) + // A wake still holds input until its resume line is submitted, as before. + if (wakeInputBufferRef.current.offer(input).kind !== 'passthrough') return + transport.write(sid, input) + }) + cleanups.push(disposeInput) + cleanups.push( + transport.onExit(sid, (code) => { + if (life.dead) return + disposeInput() + term.write(`\r\n\x1b[90m[process exited with code ${code}]\x1b[0m\r\n`) + if (code === 255 && sshProjectId && dialsSsh) sshDropHandler?.(sshProjectId, id) + }) + ) // `?? true`: an absent flag is an older core over the relay, not a plain shell. sessionPersistent = persistent ?? true // Published for the mount-stable observer effect, which cannot see this closure. @@ -3746,6 +3791,7 @@ export function TerminalNode({ if (transport.onClosed) { cleanups.push( transport.onClosed(sid, ({ by }) => { + disposeInput() setCo(termKey, { closed: { by } }) term.write('\r\n\x1b[90m[session closed by another user]\x1b[0m\r\n') }) @@ -3761,6 +3807,7 @@ export function TerminalNode({ if (transport.onRecycled) { cleanups.push( transport.onRecycled(sid, (info) => { + disposeInput() if (recycleAction(info) === 'ended') { disposeParkedTerminal(termKey) // the park holds a dead pty either way setCo(termKey, { ended: true }) @@ -3947,30 +3994,6 @@ export function TerminalNode({ // Seed written — release the PTY output that arrived while it was in flight. if (!toreDown) gate.open() } - cleanups.push( - transport.onExit(sid, (code) => { - term.write(`\r\n\x1b[90m[process exited with code ${code}]\x1b[0m\r\n`) - // ssh exiting 255 on an SSH-project terminal is a CONNECTION drop (sleep/wake, - // network change, NAT idle) — the remote tmux session survives. Report it so the - // reconnect coordinator can re-establish the master and respawn this node. - if (code === 255 && sshProjectId && dialsSsh) sshDropHandler?.(sshProjectId, id) - }) - ) - cleanups.push( - term.onData((input) => { - // Lone Esc / Ctrl-C while the agent works: Claude Code fires NO hook on a user - // interrupt, so probe the cancelled turn (still-silent working → done). Exact - // match — arrow keys etc. arrive as multi-byte \x1b[… sequences. - if (showStatus && (input === '\x1b' || input === '\x03')) inferInterruptAfterSettle(id) - // While a wake is in flight, HOLD this input rather than write it: the resume line is - // sitting un-submitted in the pane and a keystroke would splice into it. The buffer is - // bounded — a `queueFull`/`buffered` verdict means "held, do not write". Flushed (or - // dropped) when the resume resolves; see `wakeInputBufferRef`. `passthrough` is the - // ordinary case and is byte-for-byte the old behaviour. - if (wakeInputBufferRef.current.offer(input).kind !== 'passthrough') return - transport.write(sid, input) - }).dispose - ) // Deliver a command only after the fresh shell settles, and never blind: zsh's init // (rc files / ZLE setup) resets the tty with a FLUSH that can eat part of a queued // line — a long agent launch line then sat at the prompt mangled (unbalanced quote → @@ -5055,6 +5078,9 @@ export function TerminalNode({ return () => { disposed = true + // A park can hand the SAME Terminal to another mount. Retire this run's pending blur here, + // rather than in park-carried cleanups, before its ref is cleared or its element is moved. + deferredBlur.cancel() // Nothing may restart a node that is no longer mounted — park, respawn and real teardown all // pass through here. A remount re-registers (superseding, so a stale unregister is inert). unregisterRestart() @@ -5564,6 +5590,7 @@ export function TerminalNode({ * jump. The one caller that passes false is the window-activation restore. */ const enterNow = (opts?: { ack?: boolean }) => { + deferredBlur.cancel() const aimed = opts?.ack !== false if (dwellRef.current) clearTimeout(dwellRef.current) if (aimed) setArmed(false) @@ -5602,6 +5629,7 @@ export function TerminalNode({ dwellRef.current = null }, [focusFollowsPointer]) const onBodyEnter = () => { + deferredBlur.cancel() if (dwellRef.current) clearTimeout(dwellRef.current) // Click to focus (#757): hovering never takes the keyboard. A click still does, at once, through // the guard (`onGuardClick` → `enterNow`); a drag that started on the guard comes back here and, @@ -5617,6 +5645,7 @@ export function TerminalNode({ return } setArmed(false) + deferredBlur.cancel() focusXtermUnlessCovered(termRef.current, mdModeRef.current) useTerminalFocus.getState().remember(id) useAgentStatus.getState().setActive(id, true) @@ -5636,7 +5665,9 @@ export function TerminalNode({ // follows where the KEYBOARD goes rather than where the mouse goes. if (!pointerLeaveReleases(focusFollowsPointer)) return setArmed(true) - termRef.current?.blur() + // Let native mouseleave reach xterm before blur repaints its DOM rows. Focus/presence release + // still happens now; only the repaint moves to the next task and is canceled on re-entry. + deferredBlur.schedule() useAgentStatus.getState().setActive(id, false) presence.releaseFocus(id) } @@ -5762,6 +5793,7 @@ export function TerminalNode({ // FIRST — otherwise the drag-source keeps keyboard focus and the user types into the wrong app. // A paste came from THIS window, which already has it. if (opts.raiseWindow) window.nodeTerminal.focusWindow() + deferredBlur.cancel() term.focus() useTerminalFocus.getState().remember(id) // A pasted image is only reported as attached once the agent's own pane shows it (claude's @@ -6360,6 +6392,16 @@ export function TerminalNode({ > ✕ Deny + {status.permissionSuggestions?.map(suggestion => ( + + ))} )} {isUnread && ( diff --git a/src/renderer/session/relay-ssh.wiring.test.ts b/src/renderer/session/relay-ssh.wiring.test.ts index c3c8cb2830..a544f25b72 100644 --- a/src/renderer/session/relay-ssh.wiring.test.ts +++ b/src/renderer/session/relay-ssh.wiring.test.ts @@ -35,6 +35,7 @@ describe('relay tabs never reach a guest-side ssh dial', () => { expect(node).toContain("const dialsSsh = sshRemoteTmux && session.source !== 'relay'") expect(node).toMatch(/dialsSsh && ssh\n\s+\? await resolveSshRemote\(/) expect(node).not.toMatch(/sshRemoteTmux && ssh\n\s+\? await resolveSshRemote\(/) - expect(node).toContain('if (!projectMayDialSsh(useProjects.getState().getProject(activeProjectId))) return undefined') + expect(node).toContain('if (!projectMayDialSsh(useProjects.getState().getProject(ownerProjectId))) return undefined') + expect(node).toContain('const projectId = sshConnectionScope(conn, ownerProjectId)') }) }) diff --git a/src/renderer/state/agentStatus.persist.test.ts b/src/renderer/state/agentStatus.persist.test.ts index 5713736e7d..4250709f9b 100644 --- a/src/renderer/state/agentStatus.persist.test.ts +++ b/src/renderer/state/agentStatus.persist.test.ts @@ -76,6 +76,30 @@ describe('loop persistence (cron/schedule survive an app restart)', () => { } }) + it('reports the live-state self-heal to the mirror, like every other change of the flag', async () => { + // The A76 review: the phone reads SLEEPING — and offers to type a wake line — off the core's + // mirror, which only carries what this store reports. A codex resume started outside our own + // wake closure (the phone's, over SSH) arrives as `working`, and an unreported clear left the + // phone offering `codex resume` into the running CLI. + vi.stubGlobal('localStorage', memStorage()) + const reportHibernated = vi.fn() + vi.stubGlobal('window', { nodeTerminal: { reportHibernated } }) + const { useAgentStatus } = await import('./agentStatus') + for (const state of ['working', 'blocked', 'waiting'] as const) { + useAgentStatus.getState().setHibernated('n13', true) + reportHibernated.mockClear() + useAgentStatus.getState().setState('n13', state, 'codex') + expect(reportHibernated.mock.calls, state).toEqual([['n13', false]]) + useAgentStatus.getState().setState('n13', undefined) + } + // `done` keeps the flag, so it reports nothing either; neither does a node that was not asleep. + useAgentStatus.getState().setHibernated('n13', true) + reportHibernated.mockClear() + useAgentStatus.getState().setState('n13', 'done', 'codex') + useAgentStatus.getState().setState('n14', 'working', 'codex') + expect(reportHibernated).not.toHaveBeenCalled() + }) + it('keeps `hibernated` through a `done` event — a late Stop must not undo the exit', async () => { vi.stubGlobal('localStorage', memStorage()) const { useAgentStatus } = await import('./agentStatus') diff --git a/src/renderer/state/agentStatus.test.ts b/src/renderer/state/agentStatus.test.ts index 134cf227aa..055b5aa0d2 100644 --- a/src/renderer/state/agentStatus.test.ts +++ b/src/renderer/state/agentStatus.test.ts @@ -305,7 +305,7 @@ describe('the verified evidence for a transition', () => { // argument list: nothing else in the suite would notice if the last argument disappeared. const src = readFileSync(resolve(__dirname, '../../..', 'src/renderer/canvas/Canvas.tsx'), 'utf8') expect(src).toMatch( - /cs\.setState\(\s*e\.nodeId,\s*e\.state,\s*e\.agentId,\s*e\.newTurn,\s*e\.pendingId,\s*e\.verified,\s*e\.errored,\s*e\.held,\s*recordsTurnInterrupt\(e\)\s*\)/ + /cs\.setState\(\s*e\.nodeId,\s*e\.state,\s*e\.agentId,\s*e\.newTurn,\s*e\.pendingId,\s*e\.verified,\s*e\.errored,\s*e\.held,\s*recordsTurnInterrupt\(e\),\s*e\.permissionSuggestions\s*\)/ ) }) @@ -351,6 +351,25 @@ describe('the verified evidence for a transition', () => { }) }) + +describe('held permission rule capabilities', () => { + it('updates same-state capabilities and retires them across tickets and working transitions', () => { + const id = nid() + const suggestion = [{ index: 1, label: 'Bash(npm test) — session' }] + const held = { pendingId: 'old', toolName: 'Bash' } + useAgentStatus.getState().setState(id, 'blocked', 'claude', false, 'old', true, false) + useAgentStatus.getState().setState(id, 'blocked', 'claude', false, 'old', true, false, held, undefined, suggestion) + expect(useAgentStatus.getState().byId[id].permissionSuggestions).toEqual(suggestion) + expect(useAgentStatus.getState().byId[id].held).toEqual(held) + useAgentStatus.getState().setState(id, 'blocked', 'claude', false, 'new', true, false) + expect(useAgentStatus.getState().byId[id].permissionSuggestions).toBeUndefined() + useAgentStatus.getState().setState(id, 'blocked', 'claude', false, 'new', true, false, undefined, undefined, suggestion) + useAgentStatus.getState().setState(id, 'working', 'claude') + expect(useAgentStatus.getState().byId[id].permissionSuggestions).toBeUndefined() + expect(useAgentStatus.getState().byId[id].held).toBeUndefined() + }) +}) + describe('held permission request (structured answers)', () => { const plan = { pendingId: 'n-1-1', toolName: 'ExitPlanMode' } const question = { pendingId: 'n-2-2', toolName: 'AskUserQuestion' } diff --git a/src/renderer/state/agentStatus.ts b/src/renderer/state/agentStatus.ts index 3ee369b4ef..a9b1bece13 100644 --- a/src/renderer/state/agentStatus.ts +++ b/src/renderer/state/agentStatus.ts @@ -1,6 +1,7 @@ import { create, type StoreApi, type UseBoundStore } from 'zustand' import { WORKING_STALE_MS } from '@shared/agents/stale' import type { AgentId } from '@shared/agents/config' +import type { PermissionSuggestion } from '@shared/hook-answers' import type { AgentState } from '@shared/agents/normalize' import type { HeldPermission } from '@shared/agents/permission-answer' import type { NodeTerminalApi, ObservedClaudeAccount } from '@shared/types' @@ -229,6 +230,7 @@ export interface AgentNodeStatus { * leaves `blocked` (so the buttons vanish once the decision lands). Absent = legacy prompt path. */ pendingId?: string + permissionSuggestions?: PermissionSuggestion[] /** * The request the node's managed hook is HOLDING (answer-file ticket + tool name), so a surface * can offer controls that fit it — approve a plan with a follow-on mode, answer a question — @@ -351,7 +353,8 @@ export interface AgentStatusStore { verified?: boolean, errored?: boolean, held?: HeldPermission, - interrupted?: boolean + interrupted?: boolean, + permissionSuggestions?: PermissionSuggestion[] ): void /** * Subscribe to EVERY hook event `setState` records for node `id` — same-state ones included, and @@ -760,7 +763,7 @@ export function createAgentStatusSession( return s.activeId === id ? { activeId: null } : s }), - setState: (id, state, agentId, newTurn, pendingId, verified, errored, held, interrupted) => { + setState: (id, state, agentId, newTurn, pendingId, verified, errored, held, interrupted, permissionSuggestions) => { set((s) => { const prev = s.byId[id] ?? EMPTY const now = Date.now() @@ -787,7 +790,8 @@ export function createAgentStatusSession( // path so the header buttons retarget the new answer file — otherwise treat same-state as // a freshness-only refresh. const samePendingWhileBlocked = - state !== 'blocked' || (pendingId ?? prev.pendingId) === prev.pendingId + state !== 'blocked' || ((pendingId ?? prev.pendingId) === prev.pendingId && + JSON.stringify(permissionSuggestions ?? prev.permissionSuggestions) === JSON.stringify(prev.permissionSuggestions)) // Same for a NEW held request (a held picker re-asserts `waiting`, where pendingId is absent). const needsYou = state === 'blocked' || state === 'waiting' const sameHeld = !needsYou || !held || held.pendingId === prev.held?.pendingId @@ -838,6 +842,9 @@ export function createAgentStatusSession( if (agentId !== undefined) next.agentId = agentId // Retain the approval ticket only while blocked; any other state clears it (transient). next.pendingId = state === 'blocked' ? (pendingId ?? prev.pendingId) : undefined + next.permissionSuggestions = state === 'blocked' + ? permissionSuggestions ?? (pendingId && pendingId !== prev.pendingId ? undefined : prev.permissionSuggestions) + : undefined // The held request rides both needs-you states (see `held`); anything else ends the hold. next.held = needsYou ? (held ?? prev.held) : undefined // The last-turn verdict (issue #521). A genuine new turn retires it — the station is being @@ -888,6 +895,16 @@ export function createAgentStatusSession( if (alive && prev.hibernated) { next.hibernated = undefined next.hibernatedContext = undefined // goes with the flag, always + // Reported like every other change of the flag (see `setHibernated`): the agent-status + // mirror only carries it, and the phone reads SLEEPING — and offers a wake line — off + // that mirror. Unreported, a CLI resumed by anything but our own wake closure (the + // phone's wake over SSH, a hand-typed resume) stayed SLEEPING there for good: a codex + // SessionStart arrives as `working`, never as the session start Canvas also clears on. + try { + window.nodeTerminal?.reportHibernated?.(id, false) + } catch { + /* the mirror is a side-channel; a failed report must never break the store */ + } } // Same self-heal, same reasoning: a live hook event is proof the CLI is running, whatever // brought it back (our own resume, or the user typing the launch line by hand) — a standing diff --git a/src/renderer/terminal/deferred-blur.test.ts b/src/renderer/terminal/deferred-blur.test.ts new file mode 100644 index 0000000000..78bc6667d9 --- /dev/null +++ b/src/renderer/terminal/deferred-blur.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createDeferredBlur } from './deferred-blur' + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it('waits for a timer task so native mouseleave precedes a blur repaint', async () => { + const events: string[] = [] + const terminal = { blur: () => events.push('blur repaint') } + const deferred = createDeferredBlur(() => terminal) + + events.push('mouseout') + deferred.schedule() + expect(events).toEqual(['mouseout']) + // A microtask would still run before the browser finishes the native leave sequence. + await Promise.resolve() + expect(events).toEqual(['mouseout']) + events.push('native mouseleave') + vi.advanceTimersByTime(0) + expect(events).toEqual(['mouseout', 'native mouseleave', 'blur repaint']) +}) + +it('re-entry or explicit focus cancels the pending blur and its timer', () => { + const terminal = { blur: vi.fn() } + const deferred = createDeferredBlur(() => terminal) + deferred.schedule() + deferred.cancel() + expect(vi.getTimerCount()).toBe(0) + vi.runAllTimers() + expect(terminal.blur).not.toHaveBeenCalled() +}) + +it('cleanup rejects a queued old blur even when the next run adopts the same terminal', () => { + const terminal = { blur: vi.fn() } + const timers = vi.spyOn(globalThis, 'setTimeout') + const deferred = createDeferredBlur(() => terminal) + deferred.schedule() + const queuedOldCallback = timers.mock.calls[0][0] as () => void + + deferred.cancel() + // Model a callback already queued before cleanup, outside clearTimeout's reach. The object is + // unchanged after park/adopt or StrictMode replay; cancellation still retires the old run. + queuedOldCallback() + expect(terminal.blur).not.toHaveBeenCalled() + deferred.schedule() + vi.runAllTimers() + expect(terminal.blur).toHaveBeenCalledTimes(1) +}) + +it('never blurs a replaced terminal or its successor from an old leave', () => { + const oldTerminal = { blur: vi.fn() } + const replacement = { blur: vi.fn() } + let current = oldTerminal + const deferred = createDeferredBlur(() => current) + deferred.schedule() + current = replacement + vi.runAllTimers() + expect(oldTerminal.blur).not.toHaveBeenCalled() + expect(replacement.blur).not.toHaveBeenCalled() + + deferred.schedule() + vi.runAllTimers() + expect(replacement.blur).toHaveBeenCalledTimes(1) +}) + +it('does nothing if the terminal ref has been cleared before the next task', () => { + const terminal = { blur: vi.fn() } + let current: typeof terminal | null = terminal + const deferred = createDeferredBlur(() => current) + deferred.schedule() + current = null + vi.runAllTimers() + expect(terminal.blur).not.toHaveBeenCalled() +}) + +it('does not schedule a leave before a terminal exists', () => { + const deferred = createDeferredBlur(() => null) + deferred.schedule() + expect(vi.getTimerCount()).toBe(0) +}) + +it('coalesces repeated leaves into one pending blur', () => { + const terminal = { blur: vi.fn() } + const deferred = createDeferredBlur(() => terminal) + deferred.schedule() + deferred.schedule() + deferred.schedule() + expect(vi.getTimerCount()).toBe(1) + vi.runAllTimers() + expect(terminal.blur).toHaveBeenCalledTimes(1) +}) + +it('an obsolete queued callback cannot discard the newer cancelable timer', () => { + const terminal = { blur: vi.fn() } + const timers = vi.spyOn(globalThis, 'setTimeout') + const deferred = createDeferredBlur(() => terminal) + deferred.schedule() + const queuedOldCallback = timers.mock.calls[0][0] as () => void + deferred.schedule() + + queuedOldCallback() + expect(terminal.blur).not.toHaveBeenCalled() + deferred.cancel() + expect(vi.getTimerCount()).toBe(0) + vi.runAllTimers() + expect(terminal.blur).not.toHaveBeenCalled() +}) + +it('remains reusable after repeated cleanup and focus cancellation', () => { + const terminal = { blur: vi.fn() } + const deferred = createDeferredBlur(() => terminal) + deferred.cancel() + deferred.cancel() + deferred.schedule() + deferred.cancel() + deferred.schedule() + vi.runAllTimers() + expect(terminal.blur).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/renderer/terminal/deferred-blur.ts b/src/renderer/terminal/deferred-blur.ts new file mode 100644 index 0000000000..f61a5987c7 --- /dev/null +++ b/src/renderer/terminal/deferred-blur.ts @@ -0,0 +1,36 @@ +/** + * Blur after the native mouse-leave sequence has finished. A synchronous xterm blur can repaint + * and detach the mouseout target before the browser delivers mouseleave to its ancestors. + * + * Each lifecycle owner keeps its own controller and cancels it before parking or disposing a + * terminal. Cancellation invalidates even an already-queued callback: a later owner may adopt + * the same Terminal object. The controller stays reusable for React StrictMode effect replay. + */ +export function createDeferredBlur(current: () => T | null): { + schedule(): void + cancel(): void +} { + let pending: ReturnType | null = null + let generation = 0 + + const cancel = () => { + generation++ + if (pending !== null) clearTimeout(pending) + pending = null + } + + return { + cancel, + schedule() { + cancel() + const terminal = current() + if (!terminal) return + const scheduledGeneration = generation + pending = setTimeout(() => { + if (generation !== scheduledGeneration) return + pending = null + if (current() === terminal) terminal.blur() + }, 0) + } + } +} diff --git a/src/renderer/terminal/file-links.test.ts b/src/renderer/terminal/file-links.test.ts index b6051c7bd8..b59f0a80ed 100644 --- a/src/renderer/terminal/file-links.test.ts +++ b/src/renderer/terminal/file-links.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' -import type { Terminal } from '@xterm/xterm' +import type { ILink, Terminal } from '@xterm/xterm' import { createOsc8LinkHandler, + createUrlLinkProvider, makeDirListingLookup, matchFileTokens, matchUrlTokens, @@ -174,6 +175,38 @@ describe('paragraphContaining', () => { const p = paragraphContaining(view(10, wall), 0)! expect(p.rows).toBeLessThanOrEqual(32) }) + + it.each(['hard', 'soft'])('keeps every hovered row inside a capped %s-wrapped paragraph', (wrap) => { + const text = Array.from({ length: 80 }, (_, row) => String(row).padStart(10, '0')) + const v = view(10, text.map((line, row) => wrap === 'soft' && row > 0 ? `w:${line}` : line)) + for (let row = 0; row < text.length; row++) { + const p = paragraphContaining(v, row)! + expect(p.startRow).toBeLessThanOrEqual(row) + expect(p.startRow + p.rows, `hovered row ${row}`).toBeGreaterThan(row) + expect(p.rows).toBeLessThanOrEqual(32) + const offset = (row - p.startRow) * v.cols + expect(p.text.slice(offset, offset + v.cols)).toBe(text[row]) + } + }) + + it('offers the complete URL when hovering its tail beyond the paragraph cap', () => { + const v = view(10, [...Array.from({ length: 38 }, () => 'abcdefghij'), '!https://x', '.io/a']) + const term = { + cols: v.cols, + buffer: { active: { length: v.length, getLine: (row: number) => { + const line = v.line(row) + return line && { isWrapped: line.isWrapped, translateToString: line.text } + } } } + } as unknown as Terminal + const opened: string[] = [] + let links: ILink[] | undefined + createUrlLinkProvider(term, (url) => opened.push(url)).provideLinks(40, (found) => { links = found }) + expect(links).toHaveLength(1) + expect(links![0].text).toBe('https://x.io/a') + expect(links![0].range).toEqual({ start: { x: 2, y: 39 }, end: { x: 5, y: 40 } }) + links![0].activate({ ctrlKey: true, metaKey: false } as MouseEvent, links![0].text) + expect(opened).toEqual(['https://x.io/a']) + }) }) describe('matchUrlTokens', () => { diff --git a/src/renderer/terminal/file-links.ts b/src/renderer/terminal/file-links.ts index 225a3cdbb5..3182c754b9 100644 --- a/src/renderer/terminal/file-links.ts +++ b/src/renderer/terminal/file-links.ts @@ -428,7 +428,7 @@ export function bufferView(term: Terminal): BufferView { } } -/** Upper bound on rows joined in each direction — bounds hover work on pathological +/** Upper bound on total rows joined — bounds hover work on pathological * full-width walls of text; a wrapped OAuth URL is ~7 rows at 80 cols. */ const MAX_JOIN_ROWS = 32 @@ -488,7 +488,8 @@ export interface Paragraph { export function paragraphContaining(view: BufferView, row: number): Paragraph | null { if (!view.line(row)) return null let start = row - while (start > 0 && row - start < MAX_JOIN_ROWS && continuesOnNextRow(view, start - 1)) start-- + // Reserve one row for the hovered row within the capped downward window. + while (start > 0 && row - start < MAX_JOIN_ROWS - 1 && continuesOnNextRow(view, start - 1)) start-- let text = '' const cellStart: number[] = [] const cellEnd: number[] = [] diff --git a/src/renderer/terminal/offscreen-wake-runtime.test.ts b/src/renderer/terminal/offscreen-wake-runtime.test.ts new file mode 100644 index 0000000000..a334deea05 --- /dev/null +++ b/src/renderer/terminal/offscreen-wake-runtime.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { SleepingWakeRequest } from '@shared/agents/sleeping-wake' +import { DEFAULT_SETTINGS, type CanvasNodeState, type Project } from '@shared/types' +import { useProjects } from '../state/projects' +import { useSettings } from '../state/settings' +import { useAgentStatus } from '../state/agentStatus' +import { resetProjectLaunchInfoForTests } from '../state/projectLaunchInfo' +import { resetCodexCliCapsForTests } from '../state/codexCli' +import { resetClaudeCliCapsForTests } from '../state/permissionMode' +import { useSshConn } from '../state/sshConn' +import { wakeOffscreenNode } from './offscreen-wake-runtime' + +const node: CanvasNodeState = { id: 'term-hidden', kind: 'terminal', title: 'G', color: '#fff', group: null, + position: { x: 0, y: 0 }, size: { width: 600, height: 400 }, agentId: 'gemini', agentSessionId: 'saved-id' } +const hidden: Project = { id: 'hidden', name: 'Hidden', color: '#fff', cwd: '/hidden', nodes: [node], viewport: { x: 0, y: 0, zoom: 1 }, defaultPermissionMode: 'plan' } +const active: Project = { ...hidden, id: 'active', name: 'Active', nodes: [], defaultPermissionMode: 'bypassPermissions' } +const wake = vi.fn(async (_request: SleepingWakeRequest) => ({ delivered: true, verdict: 'resume' as const })) +const launchInfo = vi.fn(async () => ({ resolved: { setup: {}, worktree: {}, agents: {}, terminal: {} }, trusted: { agents: true, shell: true } })) +const localClaude = vi.fn(async () => ({ version: '2.1.300', autoPermissionMode: true, fullscreenTui: true, sessionIdFlag: true })) +const localCodex = vi.fn(async () => ({ approvalValues: ['untrusted', 'on-request', 'never'] })) +beforeEach(() => { + vi.stubGlobal('window', { nodeTerminal: { pty: { wakeSleeping: wake }, projectSettings: { launchInfo }, + claude: { cliCaps: localClaude }, codex: { cliCaps: localCodex }, reportHibernated: vi.fn() } }) + wake.mockClear(); launchInfo.mockClear(); localClaude.mockClear(); localCodex.mockClear() + resetProjectLaunchInfoForTests(); resetCodexCliCapsForTests(); resetClaudeCliCapsForTests() + useSshConn.setState({ byProject: {}, autoPermByProject: {}, remoteClaudeVersionByProject: {} }) + useSettings.setState({ settings: { ...DEFAULT_SETTINGS, claudePermissionMode: 'bypassPermissions' } }) + useProjects.getState().hydrate({ version: 2, activeProjectId: 'active', projects: [active, hidden] }) + useAgentStatus.setState({ byId: { 'term-hidden': { unread: false, hibernated: true, sessionId: 'live-id', + hibernatedContext: { command: 'bash', panePid: 72, paneId: '%8' } } } }) +}) +afterEach(() => { vi.unstubAllGlobals() }) + +describe('saved-project offscreen wake integration', () => { + it('uses the inactive owner permission mode and live provider id without switching the visible tab', async () => { + await wakeOffscreenNode('term-hidden', true) + expect(wake).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ nodeId: 'term-hidden', agentId: 'gemini', command: 'gemini --resume live-id --approval-mode plan' })) + expect(launchInfo).toHaveBeenCalledWith('hidden') + expect(useProjects.getState().activeProjectId).toBe('active') + expect(useAgentStatus.getState().byId['term-hidden'].hibernated).toBeUndefined() + }) + it('keeps a closed project closed while waking its saved node after canvas eviction', async () => { + useProjects.getState().closeProject('hidden') + const closed = useProjects.getState().getProject('hidden')! + expect(closed.closed).toBe(true) + await wakeOffscreenNode('term-hidden', false) + expect(wake).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ command: 'gemini --resume live-id --approval-mode plan' })) + expect(useProjects.getState().getProject('hidden')).toBe(closed) + expect(useProjects.getState().getProject('hidden')?.closed).toBe(true) + expect(useProjects.getState().activeProjectId).toBe('active') + }) + it('keeps the existing legacy Claude tag identity without adopting a hand-launched plain shell', async () => { + useProjects.getState().replaceProject({ ...hidden, nodes: [{ ...node, agentId: undefined, tags: ['claude'] }] }) + await wakeOffscreenNode('term-hidden', false) + expect(wake).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ agentId: 'claude', command: 'claude --resume live-id --permission-mode plan' })) + wake.mockClear() + useProjects.getState().replaceProject({ ...hidden, nodes: [{ ...node, agentId: undefined }] }) + useAgentStatus.setState({ byId: { 'term-hidden': { unread: false, hibernated: true, agentId: 'claude', sessionId: 'live-id' } } }) + await wakeOffscreenNode('term-hidden', false) + expect(wake).not.toHaveBeenCalled() + }) + it('refuses custom resume arguments whose required environment values are missing', async () => { + useSettings.setState({ settings: { ...DEFAULT_SETTINGS, customAgents: [{ id: 'custom:proxy', label: 'Proxy', launchCmd: 'proxy', args: '--token ${env:NODETERM_QA_MISSING_WAKE_TOKEN}' }] } }) + useProjects.getState().replaceProject({ ...hidden, nodes: [{ ...node, agentId: 'custom:proxy' }] }) + await wakeOffscreenNode('term-hidden', false) + expect(wake).not.toHaveBeenCalled() + }) + it('uses actual local Codex caps but never borrows that vocabulary for a remote project', async () => { + useProjects.getState().replaceProject({ ...hidden, nodes: [{ ...node, agentId: 'codex' }], defaultPermissionMode: 'manual' }) + await wakeOffscreenNode('term-hidden', false) + expect(wake.mock.calls[0][0]).toMatchObject({ command: 'codex resume live-id --ask-for-approval untrusted' }) + useAgentStatus.setState({ byId: { 'term-hidden': { unread: false, hibernated: true, sessionId: 'live-id', hibernatedContext: { command: 'bash', panePid: 72, paneId: '%8' } } } }) + wake.mockClear(); localCodex.mockClear() + useProjects.getState().replaceProject({ ...hidden, nodes: [{ ...node, agentId: 'codex' }], defaultPermissionMode: 'manual', ssh: { server: { host: 'remote.example', user: 'u' }, remoteCwd: '/remote' } }) + await wakeOffscreenNode('term-hidden', false) + expect(wake.mock.calls[0][0]).toMatchObject({ command: 'codex resume live-id' }) + expect(localCodex).not.toHaveBeenCalled() + }) + it('honors the remote project own Claude auto support while ignoring the local capability', async () => { + useProjects.getState().replaceProject({ ...hidden, nodes: [{ ...node, agentId: 'claude' }], defaultPermissionMode: 'auto', ssh: { server: { host: 'remote.example', user: 'u' }, remoteCwd: '/remote' } }) + await wakeOffscreenNode('term-hidden', false) + expect(wake.mock.calls[0][0]).toMatchObject({ command: 'claude --resume live-id' }) + expect(localClaude).not.toHaveBeenCalled() + }) + it('refuses ambiguous node ownership and automatic paused nodes without any delivery', async () => { + useProjects.getState().replaceProject({ ...active, nodes: [node] }) + await wakeOffscreenNode('term-hidden', false) + expect(wake).not.toHaveBeenCalled() + useProjects.getState().replaceProject(active) + useAgentStatus.getState().setPaused('term-hidden', true) + await wakeOffscreenNode('term-hidden', true) + expect(wake).not.toHaveBeenCalled() + }) + it('cancels when saved project is replaced while trusted launch info is being read', async () => { + let finish!: () => void + launchInfo.mockImplementationOnce(() => new Promise((resolve) => { finish = () => resolve({ resolved: { setup: {}, worktree: {}, agents: {}, terminal: {} }, trusted: { agents: true, shell: true } }) })) + const pending = wakeOffscreenNode('term-hidden', false) + await Promise.resolve(); await Promise.resolve() + useProjects.getState().replaceProject({ ...hidden, cwd: '/other' }) + finish(); await pending + expect(wake).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/terminal/offscreen-wake-runtime.ts b/src/renderer/terminal/offscreen-wake-runtime.ts new file mode 100644 index 0000000000..bc144e197b --- /dev/null +++ b/src/renderer/terminal/offscreen-wake-runtime.ts @@ -0,0 +1,59 @@ +import { assembleResumeCommand } from '@shared/agents/launch' +import { agentConfig, createdAgentId } from '@shared/agents/config' +import { wakeRefusalReason, type WakeVerdict } from '@shared/agents/wake-identity' +import { useAgentStatus } from '../state/agentStatus' +import { useProjects } from '../state/projects' +import { useSettings } from '../state/settings' +import { ensureClaudeCliCaps, projectPermissionMode } from '../state/permissionMode' +import { ensureCodexCliCaps, codexApprovalCaps } from '../state/codexCli' +import { ensureProjectLaunchInfo } from '../state/projectLaunchInfo' +import { agentLaunchOverride } from '../state/workspace' +import { agentEnvSnapshot } from '../lib/agentEnv' +import { wakeOffscreen, type OffscreenWakeTarget } from './offscreen-wake' + +/** Renderer-private resolver: an ambiguous saved id is not proof of a project or a pane. */ +export function wakeOffscreenNode(nodeId: string, automatic = false): Promise { + const matches = useProjects.getState().projects.flatMap((project) => + project.nodes.filter((node) => node.id === nodeId && node.kind === 'terminal').map((node) => ({ project, node }))) + if (matches.length !== 1) return Promise.resolve() + const { project, node } = matches[0] + const status = useAgentStatus.getState().byId[nodeId] + const settings = useSettings.getState().settings + const agentId = createdAgentId(node) + const sessionId = status?.sessionId || node.agentSessionId + if (!agentId || !sessionId) return Promise.resolve() + const target: OffscreenWakeTarget = { projectId: project.id, nodeId, agentId, sessionId, + hibernated: !!status?.hibernated, paused: !!status?.paused, recorded: status?.hibernatedContext } + return wakeOffscreen(nodeId, automatic, { + resolve: () => target, + current: () => useProjects.getState().getProject(project.id) === project && + useAgentStatus.getState().byId[nodeId] === status && useSettings.getState().settings === settings, + command: async () => { + await ensureProjectLaunchInfo(project.id) + const remote = project.ssh || node.ssh || node.sshRemoteTmux + // A local capability never gates an agent running on a remote SSH host. + if (!remote && agentId === 'claude') await ensureClaudeCliCaps() + if (!remote && agentId === 'codex') await ensureCodexCliCaps() + const assembled = assembleResumeCommand({ agentId, sessionId, + customAgent: agentConfig(agentId) ? undefined : settings.customAgents.find((c) => c.id === agentId), + permissionMode: projectPermissionMode(project, agentId), approvalCaps: codexApprovalCaps(remote), + sharedIdentity: false, launchCmdOverride: agentLaunchOverride(agentId, project.id) + }, agentEnvSnapshot()) + return assembled.missingEnv.length ? null : assembled.command || null + }, + deliver: (request) => window.nodeTerminal.pty.wakeSleeping(request), + completed: () => { + const store = useAgentStatus.getState() + store.setHibernated(nodeId, false) + store.setPaused(nodeId, false) + store.setDropped(nodeId, false) + store.setWakeBlocked(nodeId, null) + }, + refused: (_id, result) => { + const reason = result.verdict === 'delivery-failed' ? 'Resume could not be delivered. Open the session and try again.' + : result.verdict === 'invalid-request' ? 'This session has no usable resume command.' + : wakeRefusalReason(result.verdict as WakeVerdict) + useAgentStatus.getState().setWakeBlocked(nodeId, reason) + } + }).catch(() => {}) +} diff --git a/src/renderer/terminal/offscreen-wake.test.ts b/src/renderer/terminal/offscreen-wake.test.ts new file mode 100644 index 0000000000..c4e3e61a41 --- /dev/null +++ b/src/renderer/terminal/offscreen-wake.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from 'vitest' +import type { SleepingWakeResult } from '@shared/agents/sleeping-wake' +import { wakeOffscreen, type OffscreenWakeIo, type OffscreenWakeTarget } from './offscreen-wake' +import { guardConcurrentRestart } from './agent-restart' + +const target: OffscreenWakeTarget = { projectId: 'hidden-project', nodeId: 'term-sleep', agentId: 'gemini', sessionId: 's1', hibernated: true, paused: false, + recorded: { panePid: 55, paneId: '%9', command: 'bash' } } +function setup() { + return { resolve: (() => target) as OffscreenWakeIo['resolve'], current: (() => true) as OffscreenWakeIo['current'], + command: vi.fn(async (t) => t.projectId === 'hidden-project' ? 'gemini --resume s1 --approval-mode plan' : null), + deliver: vi.fn(async () => ({ delivered: true, verdict: 'resume' })), + completed: vi.fn(), refused: vi.fn(), wait: vi.fn(async () => {}) } +} + +describe('offscreen wake uses saved ownership without changing the visible project', () => { + it('assembles for the saved owner and carries exact pane proof to core', async () => { + const io = setup() + await wakeOffscreen('term-sleep', true, io) + expect(io.deliver).toHaveBeenCalledExactlyOnceWith({ nodeId: 'term-sleep', agentId: 'gemini', command: 'gemini --resume s1 --approval-mode plan', exitedByUs: true, recorded: target.recorded }) + expect(io.completed).toHaveBeenCalledExactlyOnceWith(target) + }) + it('automatic attach leaves a paused node untouched but explicit wake may resume it', async () => { + const io = setup(); io.resolve = () => ({ ...target, paused: true }) + await wakeOffscreen('term-sleep', true, io) + expect(io.deliver).not.toHaveBeenCalled() + await wakeOffscreen('term-sleep', false, io) + expect(io.deliver).toHaveBeenCalledTimes(1) + }) + it('leaves unknown or awake nodes untouched', async () => { + for (const t of [null, { ...target, hibernated: false }]) { + const io = setup(); io.resolve = () => t + await wakeOffscreen('term-sleep', false, io) + expect(io.deliver).not.toHaveBeenCalled() + } + }) + it('does not deliver when command assembly awaited a replaced project or status', async () => { + const io = setup(); io.current = () => false + await wakeOffscreen('term-sleep', false, io) + expect(io.deliver).not.toHaveBeenCalled(); expect(io.completed).not.toHaveBeenCalled() + }) + it('does not clear newer sleep state when core delivery finishes after it changed', async () => { + const io = setup(); let current = true + io.current = () => current + io.deliver.mockImplementation(async () => { current = false; return { delivered: true, verdict: 'resume' } }) + await wakeOffscreen('term-sleep', false, io) + expect(io.completed).not.toHaveBeenCalled() + }) + it('shares the mounted restart exclusion and never delivers two concurrent wake requests', async () => { + const io = setup(); const finishes: Array<() => void> = [] + io.command.mockImplementation(() => new Promise((resolve) => { finishes.push(() => resolve('gemini --resume s1')) })) + const first = wakeOffscreen('term-sleep', false, io) + const second = wakeOffscreen('term-sleep', false, io) + try { + expect(io.command).toHaveBeenCalledTimes(1) + expect(await guardConcurrentRestart('term-sleep', async () => 'resumed')()).toBe('not-eligible') + } finally { + finishes.forEach((finish) => finish()) + await Promise.all([first, second]) + } + expect(io.deliver).toHaveBeenCalledTimes(1) + }) + it('retries only unreadable pre-write answers after relay registration and bounds failures', async () => { + const io = setup(); io.deliver.mockResolvedValueOnce({ delivered: false, verdict: 'unreadable' }) + await wakeOffscreen('term-sleep', true, io) + expect(io.deliver).toHaveBeenCalledTimes(2); expect(io.completed).toHaveBeenCalledTimes(1) + const failed = setup(); failed.deliver.mockResolvedValue({ delivered: false, verdict: 'unreadable' }) + await wakeOffscreen('term-sleep', true, failed) + expect(failed.deliver).toHaveBeenCalledTimes(3); expect(failed.completed).not.toHaveBeenCalled() + expect(failed.refused).toHaveBeenCalledTimes(1) + }) + it('does not retry a changed pane, missing proof, or uncertain delivery', async () => { + for (const verdict of ['context-changed', 'no-proof', 'delivery-failed'] as SleepingWakeResult['verdict'][]) { + const io = setup(); io.deliver.mockResolvedValue({ delivered: false, verdict }) + await wakeOffscreen('term-sleep', true, io) + expect(io.deliver).toHaveBeenCalledTimes(1); expect(io.completed).not.toHaveBeenCalled(); expect(io.refused).toHaveBeenCalledTimes(1) + } + }) +}) diff --git a/src/renderer/terminal/offscreen-wake.ts b/src/renderer/terminal/offscreen-wake.ts new file mode 100644 index 0000000000..c77875c911 --- /dev/null +++ b/src/renderer/terminal/offscreen-wake.ts @@ -0,0 +1,51 @@ +import type { SleepingWakeRequest, SleepingWakeResult } from '@shared/agents/sleeping-wake' +import type { WakeContext } from '@shared/agents/wake-identity' +import { guardConcurrentRestart } from './agent-restart' + +export interface OffscreenWakeTarget { + projectId: string + nodeId: string + agentId: string + sessionId: string + hibernated: boolean + paused: boolean + recorded?: WakeContext | null +} +export interface OffscreenWakeIo { + resolve(nodeId: string): OffscreenWakeTarget | null + current(target: OffscreenWakeTarget): boolean + command(target: OffscreenWakeTarget): Promise + deliver(request: SleepingWakeRequest): Promise + completed(target: OffscreenWakeTarget): void + refused(nodeId: string, result: SleepingWakeResult): void + wait?(ms: number): Promise +} + +/** Uses saved ownership, never the currently visible project's launch settings or a fresh pane. */ +export async function wakeOffscreen(nodeId: string, automatic: boolean, io: OffscreenWakeIo): Promise { + await guardConcurrentRestart(nodeId, async () => { + const target = io.resolve(nodeId) + if (!target || (!target.hibernated && !target.paused) || (automatic && target.paused)) return 'not-eligible' + const command = await io.command(target) + if (!command || !io.current(target)) return 'not-eligible' + for (let attempt = 0; attempt < 3; attempt++) { + if (!io.current(target)) return 'not-eligible' + const result = await io.deliver({ nodeId, agentId: target.agentId, command, + recorded: target.recorded, exitedByUs: target.hibernated }) + // A hook or project change while core awaited the pane must never clear a newer sleep/pause. + if (!io.current(target)) return 'not-eligible' + if (result.delivered) { + io.completed(target) + return 'resumed' + } + // Only a pre-write unreadable/absent owned pane can be retried. The attach nudge precedes + // relay registration; a delayed attach must still wake, without replaying an uncertain write. + if (result.verdict !== 'unreadable' || attempt === 2) { + io.refused(nodeId, result) + return 'not-eligible' + } + await (io.wait?.(4000) ?? new Promise((resolve) => setTimeout(resolve, 4000))) + } + return 'not-eligible' + })() +} diff --git a/src/renderer/terminal/terminal-config.test.ts b/src/renderer/terminal/terminal-config.test.ts index 2541f3d6f7..a888e1a44d 100644 --- a/src/renderer/terminal/terminal-config.test.ts +++ b/src/renderer/terminal/terminal-config.test.ts @@ -673,6 +673,61 @@ describe('terminalKeyAction', () => { }) }) +describe('Desktop native Quit from a focused terminal', () => { + const desktop = (isMac: boolean) => ({ isDesktop: true, isMac }) + const quit = (isMac: boolean, patch: Partial = {}) => + ev({ key: 'q', code: 'KeyQ', metaKey: isMac, ctrlKey: !isMac, ...patch }) + const action = (event: CopyShortcutEvent, isMac: boolean, registryOwns = false) => + terminalKeyAction(event, true, false, registryOwns, false, desktop(isMac)) + + it('leaves exact Ctrl+Q keydown and keyup to the Linux/Windows native menu', () => { + for (const type of ['keydown', 'keyup']) { + for (const key of ['q', 'Q']) { + expect(action(quit(false, { type, key }), false)).toBe('native') + } + } + }) + + it('uses Cmd+Q on macOS and preserves the opposite modifier as a terminal chord', () => { + for (const type of ['keydown', 'keyup']) { + expect(action(quit(true, { type }), true)).toBe('native') + expect(action(quit(false, { type }), true)).toBe('pass') + expect(action(quit(true, { type }), false)).toBe('pass') + } + }) + + it('does not claim extra modifiers, other printed keys or non-key phases', () => { + for (const isMac of [false, true]) { + for (const patch of [ + { altKey: true }, + { shiftKey: true }, + { ctrlKey: true, metaKey: true }, + { ctrlKey: false, metaKey: false }, + { key: 'x', code: 'KeyQ' }, + { key: 'Control', code: 'ControlLeft' }, + { type: 'keypress' }, + { type: 'blur' } + ]) { + expect(action(quit(isMac, patch), isMac)).toBe('pass') + } + } + }) + + it('keeps native Quit ahead of registry or project flags while Server keeps XON', () => { + for (const isMac of [false, true]) { + const event = quit(isMac) + // Registry/policy stand-down and the recorder cannot gate the native role. + expect(action(event, isMac, false)).toBe('native') + expect(action(event, isMac, true)).toBe('native') + expect(terminalKeyAction(event, false, true, true, false, desktop(isMac))).toBe('native') + expect( + terminalKeyAction(event, false, false, false, false, { isDesktop: false, isMac }) + ).toBe('pass') + expect(terminalKeyAction(event, false, false, false, false)).toBe('pass') + } + }) +}) + describe('seedPaint', () => { it('paints the snapshot on a cold restore, and nothing when there is none', () => { expect(seedPaint({ replay: 'cold-snapshot', superseded: false, snapshot: 'old' })).toBe( diff --git a/src/renderer/terminal/terminal-config.ts b/src/renderer/terminal/terminal-config.ts index 75720e21f8..c81a63af56 100644 --- a/src/renderer/terminal/terminal-config.ts +++ b/src/renderer/terminal/terminal-config.ts @@ -7,6 +7,7 @@ import type { TerminalCursorStyle } from '@shared/types' import { isWindowsPlatform } from '@shared/platform-utils' +import { matchesShortcut } from '@shared/shortcut' import { isKnownTerminalThemeId, resolveTerminalTheme } from './themes' /** @@ -859,6 +860,12 @@ export const SHIFT_ENTER_SEQ = '\x1b\r' export type TerminalKeyAction = CopyKeyAction | 'shift-enter' | 'bubble' | 'native' +/** Only the native Desktop shell owns Quit; a Server browser keeps its terminal keymap. */ +export interface TerminalNativeShell { + isDesktop: boolean + isMac: boolean +} + /** * Superset of `copyKeyAction` used by the terminal's custom key handler. * @@ -881,8 +888,19 @@ export function terminalKeyAction( hasSelection: boolean, ownsProjectJump = false, registryOwns = false, - isWindows: boolean = isWindowsPlatform() + isWindows: boolean = isWindowsPlatform(), + nativeShell?: TerminalNativeShell ): TerminalKeyAction { + // Quit is an always-live native role, including terminal-first and shortcut recording. + // xterm otherwise sends Ctrl+Q as XON and cancels the keydown before the menu can see it. + // Skip both xterm key phases WITHOUT preventing default; only the exact platform chord is + // native. Server terminals have no app Quit role and retain their normal Ctrl+Q bytes. + if ( + nativeShell?.isDesktop && + (e.type === 'keydown' || e.type === 'keyup') && + matchesShortcut(e, 'Cmd+Q', nativeShell.isMac) + ) + return 'native' if ( e.type === 'keydown' && e.key === 'Enter' && diff --git a/src/renderer/terminal/wake-identity.ts b/src/renderer/terminal/wake-identity.ts index 4ec94f812f..b7bc15527b 100644 --- a/src/renderer/terminal/wake-identity.ts +++ b/src/renderer/terminal/wake-identity.ts @@ -1,172 +1,2 @@ -/** - * WHICH PANE MAY BE TYPED INTO — the identity behind Eco's exit and its much later wake. - * - * Hibernation is two writes separated by hours: `/exit` now, `claude --resume ` when the user - * comes back. Both land in a tmux pane the user can also type into, and the second one carries a - * session id that only resolves in the context the first one quit. So the pair needs an identity, - * and the question it has to answer is NOT "is something shell-shaped in this pane?" but "is this - * the shell we exited the CLI in?". - * - * ── WHY THE OLD GATE COULD NOT ANSWER IT (issue #823, measured on tmux 3.4) ───────────────────── - * - * The wake's gate was `isShellCommand(pane) || pane === hibernatedPane` — a NAME test. For a node - * whose agent is reached over an interactive `ssh` inside its pane, the names read like this - * (measured, a real pane, real ssh, real `ps`): - * - * local shell idle pane_current_command = bash fg argv = `bash …` - * ssh up, agent on far side pane_current_command = ssh fg argv = `ssh -i … localhost` - * ssh dead, login shell back pane_current_command = bash fg argv = `bash …` - * - * `pane_pid` and `pane_id` are IDENTICAL in all three readings — the pane's root process is the - * local login shell throughout, and the remote shell was never a local process at all. So after the - * fact NOTHING about the pane distinguishes "the CLI was here" from "the CLI was at the far end of - * an ssh that has since died": both present a plain local shell. The old gate passed, and the - * resume was typed into the Mac's login shell at `~`, where the remote session id cannot resolve. - * - * ── WHERE THE IDENTITY ACTUALLY LIVES ─────────────────────────────────────────────────────────── - * - * The difference is only visible while the CLI is still running, so the proof has to be taken THEN. - * `decideHibernateExit` refuses to quit a pane unless the kernel says the agent binary owns its - * foreground process group (`isAgentPane` — the same positive, three-valued predicate the delivery - * gate uses). An `ssh` pane answers `not-agent`, and so does a pane that is already sitting on a - * shell because the CLI is long gone. Neither is exited, so neither ever becomes SLEEPING, and - * there is nothing for a later wake to get wrong. - * - * `decideWakeResume` is then the cheap re-check across the gap: the same pane, still not an agent's, - * still recognisable. It cannot re-derive the proof — that moment has passed — so a record without - * one is refused rather than guessed at. That is deliberately the migration path too: a node - * hibernated by the build that shipped this bug carries no proof, and refusing it is the only - * honest answer (the node stays SLEEPING and its chip stays clickable). - */ -import { isShellCommand } from '@shared/agents/pane' -import { isAgentPane, type PaneOwner } from '@shared/agents/pane-owner-predicate' - -/** - * What a pane looked like at the instant our `/exit` was answered — the proof a wake re-checks. - * - * `panePid` and `paneId` cannot tell two SHELLS apart (see the header measurement), and they are - * not here for that. They pin the PANE: a session recycled under the node, or a node respawned onto - * a new one, changes both, and a resume aimed at a record from the pane before it would be landing - * somewhere it was never authorised for. `command` carries the allowlist-free recognition the exit - * half already relies on, so a `nu` / `xonsh` / `pwsh` user is not hibernated and then never woken. - */ -export interface WakeContext { - /** `#{pane_current_command}` once the CLI had let go. */ - command: string - /** `#{pane_pid}` — the process tmux forked for the pane. Constant for the pane's life. */ - panePid: number - /** `#{pane_id}` (`%17`) — never reused for the life of the tmux server. Absent on an older read. */ - paneId?: string -} - -/** - * `'agent-owns-pane'` is the ONLY outcome that may be followed by a write. - * - * `'not-in-this-pane'` is terminal for this node until something changes: the kernel answered and - * the agent is not in the pane's foreground group. `'unreadable'` is transient (no tmux, a pane - * that stopped existing, `ps` unavailable, the deadline lapsed) and the next sweep re-asks — it is - * never upgraded, because the exit it gates is irreversible. - */ -export type ExitContextVerdict = 'agent-owns-pane' | 'not-in-this-pane' | 'unreadable' - -/** - * May we ask the CLI in this pane to quit? - * - * Fails CLOSED on `unknown`, unlike most of this app's probes: everywhere else a probe that cannot - * answer degrades to the bare, pre-feature behaviour, but here the pre-feature behaviour is to type - * `/exit` into whatever is there. The conversation is only recoverable through the `--resume` this - * pair promises, so "we could not see the pane" must mean "leave it alone", not "quit it anyway". - */ -export function decideHibernateExit( - owner: PaneOwner | null | undefined, - agentId: string, - binaries?: readonly string[] | null -): ExitContextVerdict { - switch (isAgentPane(owner, agentId, binaries)) { - case 'agent': - return 'agent-owns-pane' - case 'not-agent': - return 'not-in-this-pane' - default: - return 'unreadable' - } -} - -/** The pane identity to remember, or null when the read gave us nothing to remember. Null is not a - * blank record — it is the absence of one, and the wake refuses on it. */ -export function captureWakeContext(owner: PaneOwner | null | undefined): WakeContext | null { - if (!owner || !owner.command || !(owner.panePid > 0)) return null - return { command: owner.command, panePid: owner.panePid, paneId: owner.paneId } -} - -/** - * `'resume'` is the only outcome that may be followed by a write. - * - * - `'agent-running'` — a CLI owns this pane again (the user relaunched one by hand, or a - * wrapper loop restarted it). A launch line typed into a live CLI is sent - * to it as a MESSAGE. Standing refusal. - * - `'no-proof'` — we exited this node ourselves but hold no record of the pane we exited - * into. Pre-fix records, and any record a failed post-exit read dropped. - * - `'context-changed'` — we hold a record and the pane no longer matches it. - * - `'unreadable'` — the pane could not be read. Transient; the caller may retry. - */ -export type WakeVerdict = 'resume' | 'agent-running' | 'no-proof' | 'context-changed' | 'unreadable' - -export interface WakeDecisionInput { - owner: PaneOwner | null | undefined - /** The proof recorded by the exit that produced this state, if any. */ - recorded: WakeContext | null | undefined - /** - * Did OUR exit put this pane on a shell (`agentStatus.hibernated`)? - * - * The two wake families need different questions, and this is what separates them. When we did - * the exit there is a specific pane we exited INTO, and matching it is the whole point. When we - * did not — a deep "pause & end session" deliberately RECYCLES the tmux session, and a `dropped` - * node's CLI died on its own — there is no prior pane to match, and requiring one would strand - * both behind a refusal they can never satisfy. Those keep exactly the shell recognition they - * have today, plus the agent-running refusal below, which they did not have. - */ - exitedByUs: boolean - agentId: string - binaries?: readonly string[] | null -} - -export function decideWakeResume(i: WakeDecisionInput): WakeVerdict { - if (!i.owner) return 'unreadable' - // Asked FIRST, and for both families: whatever else is true, a pane an agent is running in is - // never a pane to type a launch line into. - if (isAgentPane(i.owner, i.agentId, i.binaries) === 'agent') return 'agent-running' - const pane = i.owner.command - if (!pane) return 'unreadable' - if (!i.exitedByUs) return isShellCommand(pane) ? 'resume' : 'context-changed' - const rec = i.recorded - if (!rec) return 'no-proof' - if (i.owner.panePid !== rec.panePid) return 'context-changed' - // Compared only when BOTH sides carry one: a record taken before `paneId` existed, or a read the - // format expanded to nothing for, must not read as a mismatch — that is missing evidence, and - // `panePid` above is already holding the pane's identity. - if (rec.paneId && i.owner.paneId && rec.paneId !== i.owner.paneId) return 'context-changed' - if (!isShellCommand(pane) && pane !== rec.command) return 'context-changed' - return 'resume' -} - -/** Does this verdict mean "ask again in a moment"? Everything else is a standing refusal, and the - * wake's bounded retry must not spend its attempts on one. */ -export function wakeVerdictIsTransient(v: WakeVerdict): boolean { - return v === 'unreadable' -} - -/** What to tell the user about a wake that refused. One sentence, no jargon: the chip's tooltip is - * the only place any of this is ever visible. */ -export function wakeRefusalReason(v: WakeVerdict): string | null { - switch (v) { - case 'agent-running': - return 'Something is already running in this terminal, so the session was not resumed. Open it to see.' - case 'context-changed': - return 'This terminal is no longer the shell the session was paused in, so it was not resumed. Open it and resume by hand.' - case 'no-proof': - return 'This session was paused by an older version that did not record where, so it cannot be resumed safely. Open the terminal and resume by hand.' - default: - return null - } -} +// Shared by the mounted renderer wake and the core's owned offscreen delivery gate. +export * from '@shared/agents/wake-identity' diff --git a/src/renderer/terminal/webgl-budget.test.ts b/src/renderer/terminal/webgl-budget.test.ts index 38c15c9701..c197fce582 100644 --- a/src/renderer/terminal/webgl-budget.test.ts +++ b/src/renderer/terminal/webgl-budget.test.ts @@ -806,6 +806,29 @@ describe('loseWebglContexts', () => { expect(lose).toHaveBeenCalledTimes(1) }) + it('loses the original context through a retained canvas after addon detachment', () => { + const lose = vi.fn() + let attached = true + const originalContext = { getExtension: () => ({ loseContext: lose }) } + const canvas = { + get isConnected() { return attached }, + getContext(type: string) { return type === 'webgl2' ? originalContext : null } + } + // An addon detach changes DOM membership, not the context held by the captured element. + attached = false + expect(canvas.isConnected).toBe(false) + expect(loseWebglContexts([canvas] as never)).toBe(1) + expect(lose).toHaveBeenCalledTimes(1) + }) + + it('continues retiring captured contexts when an earlier canvas throws', () => { + const lose = vi.fn() + const broken = fakeCanvas(null, { throws: true }) + const live = fakeCanvas({ getExtension: () => ({ loseContext: lose }) }) + expect(loseWebglContexts([broken, live] as never)).toBe(1) + expect(lose).toHaveBeenCalledTimes(1) + }) + it('fails open on a throwing canvas and a missing extension', () => { const bad = fakeCanvas(null, { throws: true }) const noExt = fakeCanvas({ getExtension: () => null }) diff --git a/src/renderer/terminal/xterm-input.test.ts b/src/renderer/terminal/xterm-input.test.ts new file mode 100644 index 0000000000..774d786507 --- /dev/null +++ b/src/renderer/terminal/xterm-input.test.ts @@ -0,0 +1,191 @@ +// @vitest-environment jsdom +import { Terminal } from '@xterm/xterm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindXtermInput } from './xterm-input' + +// Run the installed xterm parser/CoreService, clipboard, mouse encoder and real browser focus +// methods. The small DOM only replaces geometry/rendering; no input-origin algorithm is faked. +type Core = { + element: HTMLDivElement + textarea: HTMLTextAreaElement + coreService: { triggerDataEvent(data: string, user?: boolean): void; onData(cb: (d: string) => void): { dispose(): void } } + coreMouseService: { activeProtocol: string; activeEncoding: string; triggerMouseEvent(e: object): boolean } + _handleTextAreaFocus(event: FocusEvent): void + _handleTextAreaBlur(): void +} +const views: Array<{ term: Terminal; core: Core; dispose(): void }> = [] +function view(pty: object, sessionId = 'pty-1', beforeBind?: (core: Core) => void) { + const term = new Terminal({ allowProposedApi: true }) + const core = (term as unknown as { _core: Core })._core + core.element = document.createElement('div') + core.textarea = document.createElement('textarea') + core.element.append(core.textarea) + document.body.append(core.element) + // These are the same property-lookup callbacks xterm installs in open(). + core.textarea.addEventListener('focus', (e) => core._handleTextAreaFocus(e)) + core.textarea.addEventListener('blur', () => core._handleTextAreaBlur()) + beforeBind?.(core) + const sent: string[] = [] + const dispose = bindXtermInput(term, pty, sessionId, (d) => sent.push(d)) + const result = { term, core, sent, dispose } + views.push(result) + return result +} +function paint(v: ReturnType, data: string): Promise { + return new Promise((done) => v.term.write(data, done)) +} +const da = '\x1b[c\x1b[>c' +const daReplies = ['\x1b[?1;2c', '\x1b[>0;276;0c'] +afterEach(() => { + for (const v of views.splice(0)) { v.dispose(); v.term.dispose(); v.core.element.remove() } + vi.restoreAllMocks() +}) + +describe('xterm co-view input origins and response ownership', () => { + it('uses the actual pinned private CoreService and sends automatic DA replies once in order', async () => { + const pty = {}, first = view(pty), second = view(pty) + expect(typeof first.core.coreService.triggerDataEvent).toBe('function') + expect(typeof first.core._handleTextAreaFocus).toBe('function') + expect(typeof first.core._handleTextAreaBlur).toBe('function') + // The parser calls triggerDataEvent with its default false flag, erased by public onData. + await Promise.all([paint(second, da), paint(first, da)]) + expect(first.sent).toEqual(daReplies) + expect(second.sent).toEqual([]) + // A later, identical request is intentional and still receives a response. + await Promise.all([paint(first, da), paint(second, da)]) + expect(first.sent).toEqual([...daReplies, ...daReplies]) + }) + + it('preserves every view keyboard, reply-shaped paste and actual SGR mouse input', async () => { + const pty = {}, first = view(pty), second = view(pty) + for (const v of [first, second]) { + v.term.input('key') + v.term.input(daReplies.join(''), true) + v.term.paste('\x1b[I\x1b[O' + daReplies.join('')) + v.core.coreMouseService.activeProtocol = 'ANY' + v.core.coreMouseService.activeEncoding = 'SGR' + expect(v.core.coreMouseService.triggerMouseEvent({ col: 2, row: 3, button: 0, action: 1, + ctrl: false, alt: false, shift: false })).toBe(true) + expect(v.sent).toEqual(['key', daReplies.join(''), '\x1b[I\x1b[O' + daReplies.join(''), '\x1b[<0;3;4M']) + } + await Promise.all([paint(first, da), paint(second, da)]) + expect(first.sent.slice(4)).toEqual(daReplies) + expect(second.sent).toHaveLength(4) + }) + + it('promotes the next live responder immediately and disposal cannot release another viewer', async () => { + const pty = {}, first = view(pty), second = view(pty), third = view(pty) + first.dispose(); first.dispose() + await Promise.all([paint(first, da), paint(third, da), paint(second, da)]) + expect(first.sent).toEqual([]) + expect(second.sent).toEqual(daReplies) + expect(third.sent).toEqual([]) + second.dispose(); first.dispose() + await paint(third, da) + expect(third.sent).toEqual(daReplies) + }) + + it('scopes replies by actual API identity and session generation', async () => { + const pty = {}, first = view(pty), follower = view(pty) + const anotherSession = view(pty, 'pty-2'), anotherApi = view({}, 'pty-1') + await Promise.all([first, follower, anotherSession, anotherApi].map((v) => paint(v, da))) + expect(first.sent).toEqual(daReplies) + expect(follower.sent).toEqual([]) + expect(anotherSession.sent).toEqual(daReplies) + expect(anotherApi.sent).toEqual(daReplies) + }) + + it('keeps the responder while parked, adopts without rebinding, and promotes only on final disposal', async () => { + const pty = {}, canvas = view(pty), modal = view(pty) + canvas.core.element.remove() // park retains the term, listener and actual session + await Promise.all([paint(canvas, da), paint(modal, da)]) + expect(canvas.sent).toEqual(daReplies) + expect(modal.sent).toEqual([]) + document.body.append(canvas.core.element) // adopt the same binding + await Promise.all([paint(modal, da), paint(canvas, da)]) + expect(canvas.sent).toEqual([...daReplies, ...daReplies]) + expect(() => bindXtermInput(canvas.term, pty, 'pty-1', () => {})).toThrow('already bound') + canvas.dispose() + await paint(modal, da) + expect(modal.sent).toEqual(daReplies) + }) + + it('lets the live focused secondary answer automatic focus queries in either parse order', async () => { + const pty = {}, hidden = view(pty), focused = view(pty) + focused.term.focus() + for (const order of [[hidden, focused], [focused, hidden]]) { + await Promise.all(order.map((v) => paint(v, '\x1b[?1004h'))) + } + expect(hidden.sent).toEqual([]) + expect(focused.sent).toEqual(['\x1b[I', '\x1b[I']) + // Actual blur/focus DOM transitions have false origin and must pass from a non-owner too. + focused.term.blur(); focused.term.focus() + expect(focused.sent.slice(2)).toEqual(['\x1b[O', '\x1b[I']) + focused.term.blur() + hidden.sent.length = 0; focused.sent.length = 0 + await Promise.all([paint(focused, '\x1b[?1004h'), paint(hidden, '\x1b[?1004h')]) + expect(hidden.sent).toEqual(['\x1b[O']) + expect(focused.sent).toEqual([]) + }) + + it('ignores a detached formerly focused primary when a live secondary is focused', async () => { + const pty = {}, parked = view(pty), modal = view(pty) + parked.term.focus() + parked.core.element.remove() + expect(parked.core.element.classList.contains('focus')).toBe(true) + modal.term.focus() + await Promise.all([paint(parked, '\x1b[?1004h'), paint(modal, '\x1b[?1004h')]) + expect(parked.sent).toEqual([]) + expect(modal.sent).toEqual(['\x1b[I']) + parked.dispose() + await paint(modal, da) + expect(modal.sent.slice(1)).toEqual(daReplies) + }) + + it('restores the outer origin after a nested dispatch and preserves the original this/args', () => { + const pty = {}, owner = view(pty) + const nested = view(pty, 'pty-1', (core) => { + core.coreService.onData((data) => { + if (data === 'outer-user') core.coreService.triggerDataEvent(daReplies[0]) + }) + }) + nested.term.input('outer-user', true) + expect(nested.sent).toEqual(['outer-user']) + expect(owner.sent).toEqual([]) + // Actual CoreService scroll-on-user-input and onData dispatch also require its real `this`. + nested.term.input('after-user') + expect(nested.sent).toEqual(['outer-user', 'after-user']) + }) + + it('does not overwrite a newer wrapper and a retired listener cannot write', () => { + const v = view({}) + const installed = v.core.coreService.triggerDataEvent + const later = vi.fn(function (this: Core['coreService'], ...args: [string, boolean?]) { + installed.apply(this, args) + }) + v.core.coreService.triggerDataEvent = later + v.dispose() + expect(v.core.coreService.triggerDataEvent).toBe(later) + v.term.input('retired') + expect(v.sent).toEqual([]) + expect(later).toHaveBeenCalledWith('retired', true) + }) + + it('warns on unsupported private shapes while retaining all public input and exact disposal', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let listener: ((s: string) => void) | undefined + const off = vi.fn(() => { listener = undefined }) + const term = { onData: (cb: (s: string) => void) => { listener = cb; return { dispose: off } } } as unknown as Terminal + const sent: string[] = [] + const dispose = bindXtermInput(term, {}, 'pty-1', (s) => sent.push(s)) + listener?.(daReplies.join('')) + expect(sent).toEqual([daReplies.join('')]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('preserving public input')) + const queued = listener + dispose(); dispose() + queued?.('late public dispatch') + expect(off).toHaveBeenCalledTimes(1) + expect(listener).toBeUndefined() + expect(sent).toEqual([daReplies.join('')]) + }) +}) diff --git a/src/renderer/terminal/xterm-input.ts b/src/renderer/terminal/xterm-input.ts new file mode 100644 index 0000000000..f109a5dea5 --- /dev/null +++ b/src/renderer/terminal/xterm-input.ts @@ -0,0 +1,126 @@ +import type { Terminal } from '@xterm/xterm' + +type CoreService = { triggerDataEvent(data: string, wasUserInput?: boolean): void } +type BrowserCore = { + coreService: CoreService + _handleTextAreaFocus(...args: unknown[]): void + _handleTextAreaBlur(...args: unknown[]): void +} +type Lease = { focused(): boolean } +type Origin = { user: boolean; focus: boolean } + +// Session ids can repeat in another local/relay core. This registry is deliberately limited to +// co-viewers in this renderer, sharing the exact PTY API object and actual session generation. +const responders = new WeakMap>() +const bound = new WeakSet() + +function acquire(pty: object, sessionId: string, lease: Lease): () => void { + let sessions = responders.get(pty) + if (!sessions) responders.set(pty, sessions = new Map()) + let viewers = sessions.get(sessionId) + if (!viewers) sessions.set(sessionId, viewers = []) + viewers.push(lease) + return () => { + const at = viewers.indexOf(lease) + if (at === -1) return + viewers.splice(at, 1) + if (!viewers.length) sessions.delete(sessionId) + if (!sessions.size) responders.delete(pty) + } +} + +function ownsReply(pty: object, sessionId: string, lease: Lease, data: string): boolean { + const viewers = responders.get(pty)?.get(sessionId) + // DEC1004's initial focus query runs on every emulator, including a hidden Canvas. Its answer + // must describe the focused co-viewer, rather than let the first hidden viewer send blur. + const focused = data === '\x1b[I' || data === '\x1b[O' + ? viewers?.find((viewer) => viewer.focused()) : undefined + return (focused ?? viewers?.[0]) === lease +} + +/** Bind xterm input once per live term/session. Keep this binding across Canvas park/adoption; + * retire it on session exit or final disposal. All genuine input reaches the writer unchanged. + * Only one co-viewer answers automatic terminal queries, without timers or byte deduplication. + * + * xterm 5.5's public onData erases wasUserInput. This version-bound adapter carries the flag while + * its actual CoreService dispatches onData synchronously. Focus/blur events are also tagged: + * their exact ESC[I/O notifications are genuine view changes, unlike _reportFocus queries. + * An unsupported private shape warns and retains public input, rather than discard user paste. + */ +export function bindXtermInput( + term: Terminal, + pty: object, + sessionId: string, + write: (data: string) => void +): () => void { + if (bound.has(term)) throw new Error('xterm input is already bound to a session') + const core = (term as unknown as { _core?: Partial })._core + const service = core?.coreService + if (!service || typeof service.triggerDataEvent !== 'function' || + typeof core?._handleTextAreaFocus !== 'function' || typeof core?._handleTextAreaBlur !== 'function') { + console.warn('[terminal] unsupported xterm input origin adapter; preserving public input') + let disposed = false + const listener = term.onData((data) => { if (!disposed) write(data) }) + bound.add(term) + return () => { + if (disposed) return + disposed = true + bound.delete(term) + listener.dispose() + } + } + + const browser = core as BrowserCore + const originalTrigger = service.triggerDataEvent + const originalFocus = browser._handleTextAreaFocus + const originalBlur = browser._handleTextAreaBlur + let focusData: string | undefined + let origin: Origin | undefined + let disposed = false + const lease: Lease = { + // A parked/detached terminal can retain its old focus class. Only actual connected DOM focus + // can override the ordinary responder; a live focused Modal must win over that stale class. + focused: () => !!term.textarea?.isConnected && + term.textarea.ownerDocument.activeElement === term.textarea + } + const release = acquire(pty, sessionId, lease) + bound.add(term) + const trigger: CoreService['triggerDataEvent'] = function (this: CoreService, ...args) { + const [data, wasUserInput = false] = args + const previous = origin + origin = { user: wasUserInput, focus: !wasUserInput && data === focusData } + try { originalTrigger.apply(this, args) } + finally { origin = previous } + } + const focus = function (this: BrowserCore, ...args: unknown[]): void { + const previous = focusData + focusData = '\x1b[I' + try { originalFocus.apply(this, args) } + finally { focusData = previous } + } + const blur = function (this: BrowserCore, ...args: unknown[]): void { + const previous = focusData + focusData = '\x1b[O' + try { originalBlur.apply(this, args) } + finally { focusData = previous } + } + service.triggerDataEvent = trigger + browser._handleTextAreaFocus = focus + browser._handleTextAreaBlur = blur + const listener = term.onData((data) => { + if (disposed) return + // Public/third-party input not emitted through CoreService has no origin; preserve it too. + if (!origin || origin.user || origin.focus || ownsReply(pty, sessionId, lease, data)) write(data) + }) + return () => { + if (disposed) return + disposed = true + listener.dispose() + release() + bound.delete(term) + // A later adapter may have wrapped ours. Never restore over its live method. + if (service.triggerDataEvent === trigger) service.triggerDataEvent = originalTrigger + if (browser._handleTextAreaFocus === focus) browser._handleTextAreaFocus = originalFocus + if (browser._handleTextAreaBlur === blur) browser._handleTextAreaBlur = originalBlur + } +} diff --git a/src/server/index.ts b/src/server/index.ts index bb363c0d70..ac8e25ea0b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -16,8 +16,14 @@ import type { ServerConfig } from './config' import { initPlatform } from '../core/platform' import { SettingsStore } from '../core/settings-store' import { WorkspaceStore } from '../core/workspace-store' +import { startSshActionsService } from '../core/ssh-actions' +import { ManagedTerminals } from '../core/managed-terminals' +import { createManagedTerminalPlanner } from '../core/managed-terminal-plan' +import { findInLoginPath } from '../core/exec-path' +import { codexIdentityCaps } from '../core/codex-identity-caps' +import { grokCliCaps } from '../core/grok-cli' import { registerAgentEnvIpc } from '../core/agent-env-ipc' -import { PtyManager } from '../core/pty-manager' +import { PtyManager, resolveLocalSessionShell } from '../core/pty-manager' import { registerCoreHandlers } from './handlers' import { registerGitHubIntegration } from '../core/github/integration' import { registerBoardDispatchReportIpc } from '../core/board-dispatch-report' @@ -59,6 +65,8 @@ import { refreshNodeTokens } from '../core/agents/node-token-service' import { armServerNodeIdentity } from './node-identity-arm' import { wireServerCodexSharedIdentity } from './codex-shared-identity' import { + writePendingAnswerLocal, + answerPendingHookLocal, localHeldPermissionIo, startPendingSweep, isValidPendingId, @@ -78,8 +86,11 @@ import { initAgentStatusMirror, flush as flushAgentStatusMirror, recordAgentEvent, + hookTicketStillOpen, ackDone, + mirrorOwnsNode, setMirrorSettingsProvider, + mirrorClaudeAccount, setMirrorLiveNodesProvider, setMirrorServerProvider, onInboxActionable, @@ -103,7 +114,6 @@ import { createMemoryPressureMonitor } from '../core/memory-pressure' import { createPtyPressureMonitor } from '../core/pty-pressure' import { claudeCliCaps, type ClaudeCliCaps } from '../core/claude-cli' import { codexCliCaps } from '../core/codex-cli' -import { codexIdentityCaps } from '../core/codex-identity-caps' import type { CodexCliCaps } from '../shared/types' import { UNKNOWN_CODEX_CLI_CAPS } from '../shared/types' import { claudeConfigDirFor, registerClaudeAccountsSource } from '../core/claude-config-dir' @@ -531,7 +541,7 @@ export async function startServer( ...(localCodexCaps?.noDaemon === true ? { codexNoDaemon: true } : {}), claudeAccounts: (s.claudeAccounts ?? []) .filter((a) => !a.host && !a.pending) - .map((a) => ({ id: a.id, dir: claudeConfigDirFor(a.id) })), + .map((a) => mirrorClaudeAccount(a, claudeConfigDirFor(a.id))), // Derived binary names only — never the launch command/env (see core/mirror-custom-agents.ts). customAgents: mirrorCustomAgents(s.customAgents) } @@ -604,6 +614,10 @@ export async function startServer( if (typeof nodeId !== 'string' || !isValidPendingId(pendingId)) return false // An SSH-project node has no reachable ControlMaster here (v1): answer only local nodes. if (workspaceStore.sshProjectIdForNode(nodeId)) return false + if (payload.answer === undefined && payload.decision === 'allow-always') { + return !!workspaceStore.getNode(nodeId) && hookTicketStillOpen(nodeId, pendingId, 'approval') && + await answerPendingHookLocal(nodeId, pendingId, { kind: 'allow-always', suggestionIndex: payload.suggestionIndex ?? -1 }, os.homedir()) === 'sent' + } // Same shared body as the desktop (core/agents/permission-decision.ts), local fs only. const res = await answerHeldPermission( pendingId, @@ -649,6 +663,7 @@ export async function startServer( // SSH projects (v1); a host it hosts writes its own acks here. See core/ack-sweep.ts. createAckSweeper({ handlers: { + ownsNode: mirrorOwnsNode, ackDone, onUnreadClear: (nodeId) => platform.broadcast(IPC.agentUnreadClear, nodeId) } @@ -929,6 +944,24 @@ export async function startServer( } }) + // Server Edition has no standing desktop renderer nudge consumer or SSH ControlMaster. + // Serve local Board writes and host-owned tmux creation through the shared save chain; + // leave renderer nudges and remote-project actions off. + const managedPlanner = createManagedTerminalPlanner({ store: workspaceStore, + userData: platform.userDataDir, settings: () => settingsStore.get(), trust: projectTrustStore, + executable: findInLoginPath, sessionShell: resolveLocalSessionShell, + claudeCaps: claudeCliCaps, codexCaps: codexCliCaps, grokCaps: grokCliCaps, + codexIdentity: codexIdentityCaps }) + const managedTerminals = ptyManager.supportsManagedCreation() ? new ManagedTerminals( + platform.userDataDir, workspaceStore, { + supported: () => ptyManager.supportsManagedCreation(), ...managedPlanner, + create: (options, creationId, current) => ptyManager.createManagedHeadless(options, creationId, current), + verify: (receipt, current) => ptyManager.verifyManagedPane(receipt, current), + deliver: (receipt, command, current) => ptyManager.deliverManagedLaunch(receipt, command, current) + }) : undefined + const sshActionsService = await startSshActionsService(platform.userDataDir, workspaceStore, + undefined, false, managedTerminals) + // Hosted team relay (docs/hosted-team-relay.md): this server as the relay host of a team. OFF // unless `team init` created /relay/team.json — with no team, start() answers 'no-team': // no relay listener is opened, and no host key, team.json or device id is written. What EVERY boot @@ -1159,6 +1192,7 @@ export async function startServer( return { port: 0, // nothing bound async close() { + await sshActionsService?.stop() // Kill any in-flight setup/archive run: it is a detached process group, so nothing else in // this teardown reaches it. Same call, same reason, in the serving branch's close() below. projectSetupService.disposeAll() @@ -1218,7 +1252,7 @@ export async function startServer( server.off('error', reject) resolve() }) - }) + }).catch(async (error) => { await sshActionsService?.stop(); throw error }) const addr = server.address() const port = addr && typeof addr === 'object' ? addr.port : config.port @@ -1226,6 +1260,7 @@ export async function startServer( return { port, async close() { + await sshActionsService?.stop() // Kill any in-flight setup/archive run first: it is a detached process group (setsid), so // neither the WS teardown nor ptyManager.killAll() below would ever reach it. projectSetupService.disposeAll() diff --git a/src/session-host/__fixtures__/composed-host.ts b/src/session-host/__fixtures__/composed-host.ts new file mode 100644 index 0000000000..ae06f22416 --- /dev/null +++ b/src/session-host/__fixtures__/composed-host.ts @@ -0,0 +1,87 @@ +// Actual standalone host + client transport, with only node-pty replaced by a byte recorder. +// This proves protocol/emulator behavior; it does not claim native ConPTY or live CLI acceptance. +import { spawn, type ChildProcess } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import net from 'node:net' +import { build } from 'esbuild' +import { sessionHostPaths } from '../paths' +import { readExistingSessionHostIdentity } from '../existing-host-state' +import { LineFramer, encodeFrame, SESSION_HOST_PROTOCOL_VERSION, type SessionHostRequestBody, + type SessionHostResponse, type SessionHostFrame, type SessionHostSpawnOptions } from '../protocol' + +export const composedRecorderPlugin = { + name: 'explicit-composed-native-recorder', + setup(bundle: import('esbuild').PluginBuild) { + bundle.onResolve({ filter: /^node-pty$/ }, () => ({ path: path.join(process.cwd(), 'src/session-host/__fixtures__/composed-native-recorder.ts') })) + } +} satisfies import('esbuild').Plugin + +export async function bundleComposedHost(outfile: string): Promise { + const result = await build({ absWorkingDir: process.cwd(), entryPoints: ['src/session-host/host.ts'], + bundle: true, platform: 'node', format: 'cjs', outfile, plugins: [composedRecorderPlugin], metafile: true, logLevel: 'silent' }) + return result.metafile! +} + +export async function bootComposedHost(root: string, bundlePath?: string, mode = 'on') { + const dataDir = path.join(root, 'host-data'), logPath = path.join(root, 'native-writes.jsonl') + fs.mkdirSync(dataDir, { recursive: true }); fs.writeFileSync(logPath, '', { mode: 0o600 }) + const bundle = bundlePath ?? path.join(root, 'host.cjs') + if (!bundlePath) await bundleComposedHost(bundle) + const child = spawn(process.execPath, [bundle, dataDir], { cwd: process.cwd(), stdio: 'ignore', windowsHide: true }) + const paths = sessionHostPaths(dataDir) + try { + let identity: ReturnType | undefined + for (let i = 0; i < 200; i++) { + // The actual state publisher reserves its file before writing bytes. This private startup + // wait may see that incomplete publication; admission still requires the full real reader. + try { identity = readExistingSessionHostIdentity(paths.statePath, { expectedEndpoint: paths.endpoint, expectedTokenPath: paths.tokenPath }) } + catch { identity = undefined } + if (identity?.kind === 'ready') break + if (child.exitCode !== null) throw new Error('fixture host exited before admission') + await new Promise((resolve) => setTimeout(resolve, 15)) + } + if (identity?.kind !== 'ready') throw new Error('fixture host did not become ready') + const sockets: net.Socket[] = [] + const connect = async (features: string[] = ['composed-input-v1']) => { + const socket = net.connect(identity.state.endpoint); sockets.push(socket) + const frames = new LineFramer(), received = new Map() + const pending = new Map void>() + socket.on('data', (chunk: Buffer) => { for (const frame of frames.push(chunk.toString('utf8'))) { + if (!('id' in frame)) continue + const done = pending.get(frame.id); if (done) { pending.delete(frame.id); done(frame) } else received.set(frame.id, frame) + } }) + await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('error', reject) }) + let id = 0 + const rpc = async (request: SessionHostRequestBody): Promise => { + const next = ++id + const answer = new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(next); reject(new Error('fixture RPC deadline')) }, 4000) + pending.set(next, (frame) => { clearTimeout(timer); resolve(frame) }) + }) + socket.write(encodeFrame({ id: next, ...request })) + const early = received.get(next); if (early) { received.delete(next); pending.get(next)?.(early); pending.delete(next) } + return answer + } + const hello = await rpc({ cmd: 'hello', protocolVersion: SESSION_HOST_PROTOCOL_VERSION, + token: identity.token, features: features as import('../protocol').SessionHostFeature[] }) + return { socket, rpc, hello } + } + const spawnOptions: SessionHostSpawnOptions = { cwd: root, shell: 'fixture-recorder', args: [], + env: { NT_COMPOSED_WRITE_LOG: logPath, NT_COMPOSED_MODE: mode }, cols: 80, rows: 24 } + const writes = (): Array<{ data: string; at: number }> => fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean).map((row) => JSON.parse(row)) + return { child, dataDir, paths, spawnOptions, connect, writes, async close() { + sockets.forEach((socket) => socket.destroy()); await stopComposedHost(child) + if (process.platform !== 'win32') fs.rmSync(paths.endpoint, { force: true }) + } } + } catch (error) { await stopComposedHost(child); throw error } +} + +async function stopComposedHost(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return + child.kill() + await new Promise((resolve) => { + const timer = setTimeout(() => { child.kill('SIGKILL'); resolve() }, 3000) + child.once('exit', () => { clearTimeout(timer); resolve() }) + }) +} diff --git a/src/session-host/__fixtures__/composed-native-recorder.ts b/src/session-host/__fixtures__/composed-native-recorder.ts new file mode 100644 index 0000000000..76d961acf0 --- /dev/null +++ b/src/session-host/__fixtures__/composed-native-recorder.ts @@ -0,0 +1,29 @@ +// Explicit node-pty recorder boundary for socket/Android interop; no kernel/ConPTY claim. +import fs from 'node:fs' +import { performance } from 'node:perf_hooks' +export function spawn(_file: string, _args: string[], options: { env: Record }) { + let output: ((data: string) => void) | undefined + let exit: ((event: { exitCode: number }) => void) | undefined + return { + pid: process.pid, + onData(cb: (data: string) => void) { output = cb + setTimeout(() => { + if (options.env.NT_HISTORY_SCROLL_OUTPUT !== undefined) cb(options.env.NT_HISTORY_SCROLL_OUTPUT) + else { cb('\x1b[?20'); cb(options.env.NT_COMPOSED_MODE === 'off' ? '04lfixture ready\r\n' : '04hfixture ready\r\n') } + }, 10) }, + onExit(cb: (event: { exitCode: number }) => void) { exit = cb }, + write(data: string) { + fs.appendFileSync(options.env.NT_COMPOSED_WRITE_LOG, JSON.stringify({ data, at: performance.now() }) + '\n') + if (data === 'mode-off') output?.('\x1b[?2004l') + if (data === 'history-mouse-sgr') output?.('\x1b[?1000h\x1b[?1006h') + if (data === 'history-mouse-default') output?.('\x1b[?1000h\x1b[?1006l\x1b[?1016l') + if (data === 'history-mouse-pixels') output?.('\x1b[?1000h\x1b[?1016h') + if (data === 'history-mouse-off') output?.('\x1b[?1000l\x1b[?1002l\x1b[?1003l') + if (data === 'history-more-output') output?.('\x1b[?1049l' + 'fixture later output\r\n'.repeat(10_000)) + if (['history-mouse-default', 'history-mouse-sgr', 'history-mouse-pixels', 'history-mouse-off', 'history-more-output'].includes(data)) + output?.(`\r\nhistory-control-ready:${data}\r\n`) + }, + resize() {}, pause() {}, resume() {}, + kill() { setTimeout(() => exit?.({ exitCode: 0 }), 0) } + } +} diff --git a/src/session-host/composed-input-host.test.ts b/src/session-host/composed-input-host.test.ts new file mode 100644 index 0000000000..eb4e1d3690 --- /dev/null +++ b/src/session-host/composed-input-host.test.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { bootComposedHost } from './__fixtures__/composed-host' +import { SessionHostClient } from '../core/session-host-client' +import { SessionHostPty } from '../core/session-host-pty' +import { waitForComposedEnter } from '../core/composed-pty' +import type { AttachResult } from './protocol' + +const cleanup: Array<() => Promise> = [] +afterEach(async () => { for (const close of cleanup.splice(0).reverse()) await close() }) +async function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-composed-host-')) + cleanup.push(async () => { fs.rmSync(root, { recursive: true, force: true }) }) + const host = await bootComposedHost(root); cleanup.push(() => host.close()) + return host +} +const input = { kind: 'paste', text: 'full draft\nΩ 😀\x1b', enter: true } as const +function body(frame: { ok: boolean; result?: unknown }): Record { + expect(frame.ok).toBe(true); return frame.result as Record +} +async function attach(host: Awaited>, connection: Awaited>, name = 'ours') { + return body(await connection.rpc({ cmd: 'attach', name, spawn: host.spawnOptions, scrollback: 100 })) as unknown as AttachResult +} +async function awaitMode(host: Awaited>, connection: Awaited>, name = 'ours') { + for (let i = 0; i < 100; i++) { + const captured = body(await connection.rpc({ cmd: 'capture', name, full: true })) + if (String(captured.text).includes('\x1b[?2004h')) return + await new Promise((resolve) => setTimeout(resolve, 10)) + } + throw new Error('actual emulator did not observe fixture paste mode') +} + +describe('actual bundled session host explicit composed input', () => { + it('runs the real subscriber client and shim, actual split mode, full paste and separated Enter', async () => { + const host = await fixture(), client = new SessionHostClient({ userDataDir: host.dataDir }) + const painter = new SessionHostPty(client, 'ours', host.spawnOptions, 100) + cleanup.push(async () => { painter.destroy() }) + let output = '', resolve!: () => void + const mode = new Promise((done) => { resolve = done }) + painter.onData((data) => { output += data; if (output.includes('fixture ready')) resolve() }) + await painter.ready; await mode + expect(await painter.submitComposed(input, () => true)).toEqual({ status: 'delivered' }) + const writes = host.writes() + expect(writes.map((w) => w.data)).toEqual(['\x1b[200~full draft\rΩ 😀\x1b[201~', '\r']) + expect(writes[1].at - writes[0].at).toBeGreaterThanOrEqual(150) + expect(await painter.submitComposed({ kind: 'control', text: '\x03', enter: false }, () => true)).toEqual({ status: 'delivered' }) + expect(host.writes().map((w) => w.data)).toEqual([...writes.map((w) => w.data), '\x03']) + }) + it('requires negotiated feature, exact generation and actual socket subscription', async () => { + const host = await fixture(), owner = await host.connect(), old = await host.connect([]), foreign = await host.connect() + expect(body(owner.hello).features).toContain('composed-input-v1') + expect(body(old.hello).features ?? []).not.toContain('composed-input-v1') + const attached = await attach(host, owner), generation = attached.generation! + for (const [conn, gen] of [[old, generation], [foreign, generation], [owner, 'foreign-generation']] as const) { + if (conn === old) await attach(host, old) + expect(body(await conn.rpc({ cmd: 'prepareComposedV1', name: 'ours', generation: gen, input })).status).toBe('refused') + } + expect(host.writes()).toEqual([]) + }) + it('excludes cross-socket transactions and foreign commits, then consumes each ticket only once', async () => { + const host = await fixture(), a = await host.connect(), b = await host.connect() + const generation = (await attach(host, a)).generation!; await attach(host, b); await awaitMode(host, a) + const prepared = body(await a.rpc({ cmd: 'prepareComposedV1', name: 'ours', generation, input })) + expect(prepared.status).toBe('prepared'); const ticket = String(prepared.ticket) + expect(body(await b.rpc({ cmd: 'prepareComposedV1', name: 'ours', generation, input })).status).toBe('refused') + expect(body(await b.rpc({ cmd: 'writeComposedV1', name: 'ours', generation, ticket, phase: 'paste' })).status).toBe('refused') + expect(body(await a.rpc({ cmd: 'writeComposedV1', name: 'ours', generation, ticket, phase: 'paste' })).status).toBe('awaiting-enter') + await waitForComposedEnter() + expect(body(await a.rpc({ cmd: 'writeComposedV1', name: 'ours', generation, ticket, phase: 'enter' })).status).toBe('delivered') + expect(body(await a.rpc({ cmd: 'writeComposedV1', name: 'ours', generation, ticket, phase: 'paste' })).status).toBe('refused') + expect(body(await a.rpc({ cmd: 'writeComposedV1', name: 'ours', generation, ticket, phase: 'enter' })).status).toBe('refused') + expect(host.writes()).toHaveLength(2) + }) + it('invalidates a detached socket ticket while keeping another viewer and the original generation alive', async () => { + const host = await fixture(), a = await host.connect(), b = await host.connect() + const generation = (await attach(host, a)).generation!; await attach(host, b); await awaitMode(host, a) + const ticket = String(body(await a.rpc({ cmd: 'prepareComposedV1', name: 'ours', generation, input })).ticket) + expect(body(await a.rpc({ cmd: 'writeComposedV1', name: 'ours', generation, ticket, phase: 'paste' })).status).toBe('awaiting-enter') + await a.rpc({ cmd: 'detach', name: 'ours' }); await attach(host, a) + await waitForComposedEnter() + expect(body(await a.rpc({ cmd: 'writeComposedV1', name: 'ours', generation, ticket, phase: 'enter' })).status).toBe('refused') + expect(body(await b.rpc({ cmd: 'prepareComposedV1', name: 'ours', generation, input: { kind: 'control', text: '\x03', enter: false } })).status).toBe('prepared') + expect(host.writes().map((w) => w.data)).toEqual(['\x1b[200~full draft\rΩ 😀\x1b[201~']) + }) + it('sends no Enter or replay after a real subscriber retires during the separation delay', async () => { + const host = await fixture(), client = new SessionHostClient({ userDataDir: host.dataDir }) + const painter = new SessionHostPty(client, 'ours', host.spawnOptions, 100) + cleanup.push(async () => { painter.destroy() }); await painter.ready + const watcher = await host.connect(); await attach(host, watcher); await awaitMode(host, watcher) + const sending = painter.submitComposed(input, () => true) + for (let i = 0; i < 100 && !host.writes().length; i++) await new Promise((resolve) => setTimeout(resolve, 5)) + expect(host.writes()).toHaveLength(1); painter.destroy() + expect((await sending).status).toBe('uncertain') + await waitForComposedEnter(); expect(host.writes()).toHaveLength(1) + }) +}) diff --git a/src/session-host/emulator-mouse.ts b/src/session-host/emulator-mouse.ts new file mode 100644 index 0000000000..0aed028dab --- /dev/null +++ b/src/session-host/emulator-mouse.ts @@ -0,0 +1,77 @@ +import type { Terminal } from '@xterm/headless' + +type MouseEncoding = 'DEFAULT' | 'SGR' | 'SGR_PIXELS' +type MouseProtocol = 'NONE' | 'X10' | 'VT200' | 'DRAG' | 'ANY' +interface MouseEvent { + col: number + row: number + x: number + y: number + button: number + action: number + ctrl: boolean + alt: boolean + shift: boolean +} +interface MouseState { + protocol: MouseProtocol + encoding: MouseEncoding + restrict: (event: MouseEvent) => boolean + encode: (event: MouseEvent) => string +} + +const tracking: Record = { + NONE: 'none', X10: 'x10', VT200: 'vt200', DRAG: 'drag', ANY: 'any' +} +const encodings = new Set(['DEFAULT', 'SGR', 'SGR_PIXELS']) + +/** + * The installed headless 6.0 CoreMouseService (and renderer 5.5) stores tracking + * and coordinate encoding separately. Pin this narrow private seam: the public + * modes API exposes only tracking. Unknown shape/state must not become SGR input. + * See the package's CoreMouseService/InputHandler source map, not a byte guess. + */ +export function mouseState(term: Terminal): MouseState | undefined { + try { + const service = (term as unknown as { _core?: { coreMouseService?: { + activeProtocol?: unknown + activeEncoding?: unknown + _protocols?: Record + _encodings?: Record + } } })._core?.coreMouseService + const protocol = service?.activeProtocol + const encoding = service?.activeEncoding + if (typeof protocol !== 'string' || !Object.hasOwn(tracking, protocol) || + typeof encoding !== 'string' || !encodings.has(encoding) || + tracking[protocol as MouseProtocol] !== term.modes.mouseTrackingMode) return undefined + const restrict = service?._protocols?.[protocol]?.restrict + const encode = service?._encodings?.[encoding] + if (typeof restrict !== 'function' || typeof encode !== 'function') return undefined + return { protocol: protocol as MouseProtocol, encoding: encoding as MouseEncoding, + restrict: restrict as MouseState['restrict'], encode: encode as MouseState['encode'] } + } catch { + return undefined + } +} + +/** Pure encoding, no triggerMouseEvent, onData/onBinary, or native write side effects. */ +export function wheelReport(state: MouseState, up: boolean): string | undefined { + try { + // triggerMouseEvent increments zero-based cells before calling its encoder. + // Pixel coordinates remain zero-based (MouseService.getMouseReportCoords). + const event: MouseEvent = { col: 1, row: 1, x: 0, y: 0, button: 4, + action: up ? 0 : 1, ctrl: false, alt: false, shift: false } + if (state.restrict(event) !== true) return undefined + const report = state.encode(event) + return typeof report === 'string' && report.length > 0 && report.length <= 64 ? report : undefined + } catch { + return undefined + } +} + +/** Restore encoding even if tracking is off; the addon only restores tracking. */ +export function mouseEncodingRestore(state: MouseState): string { + if (state.encoding === 'SGR') return '\x1b[?1006h' + if (state.encoding === 'SGR_PIXELS') return '\x1b[?1016h' + return '\x1b[?1006l\x1b[?1016l' +} diff --git a/src/session-host/history-scroll-host.test.ts b/src/session-host/history-scroll-host.test.ts new file mode 100644 index 0000000000..cf5baff76a --- /dev/null +++ b/src/session-host/history-scroll-host.test.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { bootComposedHost } from './__fixtures__/composed-host' +import { SessionHostClient } from '../core/session-host-client' +import { SessionHostPty } from '../core/session-host-pty' +import type { AttachResult, SessionHostRequestBody } from './protocol' + +const cleanup: Array<() => Promise> = [] +afterEach(async () => { for (const close of cleanup.splice(0).reverse()) await close() }) +async function fixture(output = '\x1b[?1000l\x1b[?1006l' + 'pre-attach retained history\r\n'.repeat(30) + 'scroll fixture ready') { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-history-scroll-host-')) + cleanup.push(async () => { fs.rmSync(root, { recursive: true, force: true }) }) + const host = await bootComposedHost(root); cleanup.push(() => host.close()) + host.spawnOptions.env.NT_HISTORY_SCROLL_OUTPUT = output + return host +} +function result(frame: { ok: boolean; result?: unknown }): Record { + expect(frame.ok).toBe(true); return frame.result as Record +} +async function attach(host: Awaited>, owner: Awaited>) { + const info = result(await owner.rpc({ cmd: 'attach', name: 'ours', spawn: host.spawnOptions, scrollback: 100 })) as unknown as AttachResult + for (let i = 0; i < 100; i++) { + if (String(result(await owner.rpc({ cmd: 'capture', name: 'ours', full: true })).text).includes('scroll fixture ready')) return info + await new Promise((done) => setTimeout(done, 10)) + } + throw new Error('actual emulator fixture output was not admitted') +} + +describe('actual bundled host/client native history scroll, explicit node-pty recorder boundary', () => { + it('captures pre-attach history with zero writes through the actual client and attached shim, then follows a live mode transition', async () => { + const host = await fixture(), client = new SessionHostClient({ userDataDir: host.dataDir }) + const painter = new SessionHostPty(client, 'ours', host.spawnOptions, 100) + cleanup.push(async () => { painter.destroy() }) + let admit!: () => void, output = '' + const ready = new Promise((done) => { admit = done }) + painter.onData((data) => { output += data; if (output.includes('scroll fixture ready')) admit() }) + await painter.ready; await ready + const history = await painter.scrollForHistory(true, 3, true, () => true) + expect(history.status).toBe('history') + if (history.status === 'history') expect(history.capture?.rows.some((row) => row.text.includes('pre-attach retained'))).toBe(true) + expect(await painter.scrollForHistory(false, 2, false, () => true)).toEqual({ status: 'history' }) + expect(host.writes()).toEqual([]) + const mode = new Promise((done) => { painter.onData((data) => { if (data.includes('?1006h')) done() }) }) + painter.write('history-mouse-sgr'); await mode + expect(await painter.scrollForHistory(true, 2, false, () => true)).toEqual({ status: 'input' }) + expect(host.writes().map((row) => row.data)).toEqual(['history-mouse-sgr', '\x1b[<64;1;1M', '\x1b[<64;1;1M']) + painter.destroy(); expect((await painter.scrollForHistory(true, 1, true, () => true)).status).toBe('refused') + }) + it('requires explicit negotiated feature, exact live generation and the requesting subscribed socket', async () => { + const host = await fixture('\x1b[?1000h\x1b[?1006hscroll fixture ready') + const owner = await host.connect(['scroll-view-v1']), old = await host.connect([]), foreign = await host.connect(['scroll-view-v1']) + const info = await attach(host, owner) + result(await old.rpc({ cmd: 'attachExisting', name: 'ours', expectedGeneration: info.generation })) + const req = { cmd: 'scrollViewV1', name: 'ours', generation: info.generation!, up: true, lines: 2, capture: true } as const + expect(result(await old.rpc(req)).status).toBe('refused') + expect(result(await foreign.rpc(req)).status).toBe('refused') + expect(result(await owner.rpc({ ...req, generation: 'replaced-generation' })).status).toBe('refused') + for (const change of [{ lines: 0 }, { lines: 21 }, { lines: 1.5 }, { up: 'true' }, { capture: 1 }]) + expect(result(await owner.rpc({ ...req, ...change } as SessionHostRequestBody)).status).toBe('refused') + expect(host.writes()).toEqual([]) + expect(result(await owner.rpc(req))).toEqual({ status: 'input' }) + expect(host.writes().map((row) => row.data)).toEqual(['\x1b[<64;1;1M', '\x1b[<64;1;1M']) + result(await owner.rpc({ cmd: 'detach', name: 'ours' })) + expect(result(await owner.rpc(req)).status).toBe('refused') + expect(host.writes()).toHaveLength(2) + }) +}) diff --git a/src/session-host/history-search.test.ts b/src/session-host/history-search.test.ts new file mode 100644 index 0000000000..bc50200927 --- /dev/null +++ b/src/session-host/history-search.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import { searchRetainedGeneration } from './history-search' + +describe('retained-generation history search', () => { + it('searches the exact live generation behind the output drain', async () => { + const current = { generation: 'ours', exited: false, retiring: false, historyText: vi.fn(async () => 'old needle\n' + 'recent\n'.repeat(500)) } + expect(await searchRetainedGeneration(() => current, 'ours', 'needle')).toEqual({ searchedLines: 501, truncated: false, rows: [{ line: 0, text: 'old needle' }] }) + expect(current.historyText).toHaveBeenCalledOnce() + }) + it('refuses missing/exited/retiring/wrong generations without capture', async () => { + const capture = vi.fn(async () => '') + for (const value of [undefined, { generation: 'other', exited: false, retiring: false, historyText: capture }, { generation: 'ours', exited: true, retiring: false, historyText: capture }, { generation: 'ours', exited: false, retiring: true, historyText: capture }]) { + await expect(searchRetainedGeneration(() => value, 'ours', 'needle')).rejects.toThrow(/no longer available/) + } + expect(capture).not.toHaveBeenCalled() + }) + it('refuses replacement during output drain and propagates read failure', async () => { + let resolve!: (value: string) => void + const current = { generation: 'ours', exited: false, retiring: false, historyText: () => new Promise((done) => { resolve = done }) } + let live = current + const pending = searchRetainedGeneration(() => live, 'ours', 'needle') + live = { ...current } + resolve('needle') + await expect(pending).rejects.toThrow(/changed/) + await expect(searchRetainedGeneration(() => ({ generation: 'ours', exited: false, retiring: false, historyText: async () => { throw new Error('read failed') } }), 'ours', 'needle')).rejects.toThrow('read failed') + }) +}) diff --git a/src/session-host/history-search.ts b/src/session-host/history-search.ts new file mode 100644 index 0000000000..d0834677a5 --- /dev/null +++ b/src/session-host/history-search.ts @@ -0,0 +1,18 @@ +import { searchTerminalHistory, validHistoryQuery, type HistorySearch } from '../core/terminal-history' + +interface RetainedGeneration { + generation: string + exited: boolean + retiring: boolean + historyText(): Promise +} + +/** The additive history extension refuses absent/replaced generations instead of empty success. */ +export async function searchRetainedGeneration(resolve: () => RetainedGeneration | undefined, generation: string, query: string): Promise { + if (!validHistoryQuery(query)) throw new Error('Enter a single-line search of 1–256 characters.') + const current = resolve() + if (!current || current.exited || current.retiring || current.generation !== generation) throw new Error('This retained terminal generation is no longer available.') + const text = await current.historyText() + if (resolve() !== current || current.exited || current.retiring) throw new Error('The retained terminal changed while its history was captured.') + return searchTerminalHistory(text, query) +} diff --git a/src/session-host/host.ts b/src/session-host/host.ts index a06d1a9d0a..defe97a7c9 100644 --- a/src/session-host/host.ts +++ b/src/session-host/host.ts @@ -15,6 +15,8 @@ import fs from 'fs' import net from 'net' import { hostMessagePane } from './message-pane' +import { searchRetainedGeneration } from './history-search' +import { hostSleepingWake } from './sleeping-wake' import path from 'path' import crypto from 'crypto' import { sessionHostPaths, currentProtocolVersion, type SessionHostState } from './paths' @@ -35,6 +37,7 @@ import { } from './protocol' import { HostSession } from './session' import { sendTextWhenSettled } from '../core/settled-text' +import { ComposedPty } from '../core/composed-pty' import { paneCommand as readPaneCommand } from './process-tree' import { terminateWindowsProcessTree } from './windows-process-tree' import { publishSessionHostState } from './state-file' @@ -338,6 +341,9 @@ async function main(): Promise { /** Connections that negotiated the `geometry` feature at hello. Only these may ever receive a * `geometry` push: an older client reads any non-`data` push frame as an exit (issue #914). */ const geometrySockets = new WeakSet() + const composedSockets = new WeakSet() + const scrollSockets = new WeakSet() + const composedPanes = new WeakMap() /** Connections that negotiated the `shutdown` feature. Only these may ask the host to end every * session and exit (issue #829): the command is destructive, so an app build that never asked * for it can never trigger it by accident. */ @@ -378,6 +384,8 @@ async function main(): Promise { ? SESSION_HOST_FEATURES.filter((feature) => requested.includes(feature)) : [] if (features.includes('geometry')) geometrySockets.add(socket) + if (features.includes('composed-input-v1')) composedSockets.add(socket) + if (features.includes('scroll-view-v1')) scrollSockets.add(socket) if (features.includes('shutdown')) shutdownSockets.add(socket) return features.length > 0 ? { protocolVersion: currentProtocolVersion(), features } @@ -978,6 +986,35 @@ async function main(): Promise { }) return ok !== false ? { ok: true, result: { delivery: ok } } : { ok: false, error: 'session unavailable or delivery busy' } } + case 'prepareComposedV1': + case 'writeComposedV1': + case 'cancelComposedV1': { + const s = sessions.get(req.name) + const current = (): boolean => !!s && !s.exited && !s.retiring && + sessions.get(req.name) === s && s.generation === req.generation && + !socket.destroyed && s.subscribers.has(socket) && composedSockets.has(socket) + if (!s || !current()) return { ok: true, result: { status: 'refused' } } + let pane = composedPanes.get(s) + if (!pane) { + pane = new ComposedPty({ bracketed: () => s.bracketedPasteRequested(), write: (data) => s.proc.write(data) }) + composedPanes.set(s, pane) + } + if (req.cmd === 'prepareComposedV1') return { ok: true, result: pane.prepare(socket, req.input, current) } + if (req.cmd === 'cancelComposedV1') return { ok: true, result: pane.cancel(socket, req.ticket) } + if (req.phase !== 'paste' && req.phase !== 'enter') return { ok: true, result: { status: 'refused' } } + return { ok: true, result: await pane.write(socket, req.ticket, req.phase, current) } + } + case 'scrollViewV1': { + const s = sessions.get(req.name) + const current = (): boolean => !!s && !s.exited && !s.retiring && + sessions.get(req.name) === s && s.generation === req.generation && + !socket.destroyed && s.subscribers.has(socket) && scrollSockets.has(socket) + if (!s || !current() || typeof req.up !== 'boolean' || typeof req.capture !== 'boolean' || + !Number.isInteger(req.lines) || req.lines < 1 || req.lines > 20) { + return { ok: true, result: { status: 'refused', message: 'This terminal viewer or scroll action is no longer current.' } } + } + return { ok: true, result: await s.scrollForHistory(req.up, req.lines, req.capture, current) } + } case 'paneCommand': { const s = sessions.get(req.name) if (!s || s.exited) return { ok: true, result: { command: null } satisfies PaneCommandResult } @@ -993,6 +1030,8 @@ async function main(): Promise { ok: true, result: await hostMessagePane(() => sessions.get(req.name)).send(req.envelope, req.expected) } + case 'wakeSleepingV1': + return { ok: true, result: await hostSleepingWake(() => sessions.get(req.name))(req.generation, req.data, req.expected) } case 'capture': { const s = sessions.get(req.name) if (!s || s.exited) return { ok: true, result: { text: '' } satisfies CaptureResult } @@ -1002,6 +1041,8 @@ async function main(): Promise { } return { ok: true, result: { text } satisfies CaptureResult } } + case 'historySearchV1': + return { ok: true, result: await searchRetainedGeneration(() => sessions.get(req.name), req.generation, req.query) } case 'executeLaunch': { if (clientProtocolVersion === 1) { return { ok: false, error: 'opaque launch requires session-host protocol v2' } @@ -1050,6 +1091,8 @@ async function main(): Promise { return { ok: true, result } } case 'detach': { + const s = sessions.get(req.name) + if (s) composedPanes.get(s)?.releaseOwner(socket) await sessions.get(req.name)?.detach(socket) return { ok: true } } @@ -1170,6 +1213,7 @@ async function main(): Promise { // `detach` also returns this socket's pause ticket; without that second half, a crashed // viewer can leave the global node-pty actuator paused forever for every healthy viewer. for (const session of sessions.values()) { + composedPanes.get(session)?.releaseOwner(socket) void session.detach(socket).catch((e) => log(`detach cleanup failed name=${session.name}: ${String(e)}`) ) diff --git a/src/session-host/protocol.ts b/src/session-host/protocol.ts index 5bdae9e028..7ad90dc601 100644 --- a/src/session-host/protocol.ts +++ b/src/session-host/protocol.ts @@ -94,11 +94,17 @@ export type SessionHostRequest = | { id: number; cmd: 'pause'; name: string } | { id: number; cmd: 'resume'; name: string } | { id: number; cmd: 'sendKeys' | 'sendKeysV2'; name: string; text: string; enter: boolean } + | { id: number; cmd: 'prepareComposedV1'; name: string; generation: string; input: import('../shared/composed-input').ComposedInput } + | { id: number; cmd: 'writeComposedV1'; name: string; generation: string; ticket: string; phase: 'paste' | 'enter' } + | { id: number; cmd: 'cancelComposedV1'; name: string; generation: string; ticket: string } + | { id: number; cmd: 'scrollViewV1'; name: string; generation: string; up: boolean; lines: number; capture: boolean } | { id: number; cmd: 'paneCommand'; name: string } | { id: number; cmd: 'messageOwnerV1'; name: string } | { id: number; cmd: 'messagePasteReadyV1'; name: string } | { id: number; cmd: 'messageEnvelopeV1'; name: string; envelope: string; expected: PaneOwner } + | { id: number; cmd: 'wakeSleepingV1'; name: string; generation: string; data: string; expected: PaneOwner } | { id: number; cmd: 'capture'; name: string; full: boolean } + | { id: number; cmd: 'historySearchV1'; name: string; generation: string; query: string } | { id: number cmd: 'executeLaunch' @@ -205,8 +211,8 @@ export interface AttachResult { geometry?: { cols: number; rows: number } } /** Additive, independently negotiated capabilities (see the `hello` request). */ -export type SessionHostFeature = 'geometry' | 'shutdown' -export const SESSION_HOST_FEATURES: readonly SessionHostFeature[] = ['geometry', 'shutdown'] +export type SessionHostFeature = 'geometry' | 'composed-input-v1' | 'scroll-view-v1' | 'shutdown' +export const SESSION_HOST_FEATURES: readonly SessionHostFeature[] = ['geometry', 'composed-input-v1', 'scroll-view-v1', 'shutdown'] /** `result` of a successful `shutdown`: the session names whose process trees were ended. The * host exits right after this reply is flushed. A shutdown that could not confirm every kill diff --git a/src/session-host/session.test.ts b/src/session-host/session.test.ts index cfde65e615..2f3ba8f73a 100644 --- a/src/session-host/session.test.ts +++ b/src/session-host/session.test.ts @@ -112,6 +112,47 @@ describe('HostSession terminal-output ordering', () => { await expect(session.recordOutput('good')).resolves.toBeUndefined() await expect(session.serialize()).resolves.toBe('good') }) + + it('searches history only after the retained buffer has applied preceding output', async () => { + const applied: string[] = [] + let release!: () => void + const historyText = vi.fn(() => applied.join('')) + const term = { + ...inertTerm(), + write: vi.fn((data: string) => new Promise((resolve) => { + release = () => { applied.push(data); resolve() } + })), + historyText + } as unknown as TerminalEmulator + const session = new HostSession('history-order', SPAWN, 100, { proc: fakeProc().value, term }) + const output = session.recordOutput('retained match') + const history = session.historyText() + await Promise.resolve() + expect(historyText).not.toHaveBeenCalled() + release() + await output + await expect(history).resolves.toBe('retained match') + expect(historyText).toHaveBeenCalledTimes(1) + }) + + it.each(['exited', 'retiring'] as const)('refuses history from a session that becomes %s while output drains', async (state) => { + let release!: () => void + const historyText = vi.fn(() => 'stale buffer') + const term = { + ...inertTerm(), + write: vi.fn(() => new Promise((resolve) => { release = resolve })), + historyText + } as unknown as TerminalEmulator + const session = new HostSession('history-ended', SPAWN, 100, { proc: fakeProc().value, term }) + const output = session.recordOutput('last output') + const history = session.historyText() + await Promise.resolve() + session[state] = true + release() + await output + await expect(history).rejects.toThrow('retained terminal has exited') + expect(historyText).not.toHaveBeenCalled() + }) }) describe('HostSession connection-scoped pause ownership', () => { diff --git a/src/session-host/session.ts b/src/session-host/session.ts index 2a13a88d07..4c60f11722 100644 --- a/src/session-host/session.ts +++ b/src/session-host/session.ts @@ -2,6 +2,7 @@ import type { Socket } from 'net' import { createHash, randomUUID } from 'crypto' import * as pty from 'node-pty' import { TerminalEmulator } from './terminal-emulator' +import type { NativeScrollResult } from '../shared/history-scroll' import type { ExecuteLaunchResult, SessionHostShellDialect, @@ -138,6 +139,7 @@ export class HostSession { * asynchronously, so warm attach/capture/resize/exit all cross this barrier. Rejections heal the * shared tail while the individual recordOutput caller still observes its own failure. */ private outputTail: Promise = Promise.resolve() + private historyScrollTrusted = true private queuedOutputBytes = 0 /** Explicit protocol flow and named-pipe transport flow drain independently. Collapsing them by * socket lets a `drain` event cancel a renderer pause that is still owed. */ @@ -258,7 +260,7 @@ export class HostSession { this.setEmulatorPause(false) } }) - this.outputTail = settled.catch(() => {}) + this.outputTail = settled.catch(() => { this.historyScrollTrusted = false }) return settled } @@ -286,6 +288,36 @@ export class HostSession { return this.term.serialize(scrollback) } + async historyText(): Promise { + await this.outputTail + if (this.exited || this.retiring) throw new Error('This retained terminal has exited.') + return this.term.historyText() + } + + /** One serialized screen turn, guarded by the exact subscribed generation at each write. */ + scrollForHistory(up: boolean, lines: number, capture: boolean, current: () => boolean): Promise { + const refused = (): NativeScrollResult => ({ status: 'refused', message: 'This terminal viewer is no longer current or its mouse state is unavailable.' }) + const uncertain = (): NativeScrollResult => ({ status: 'uncertain', message: 'Wheel input may have reached the terminal. It was not sent again.' }) + const valid = (): boolean => this.historyScrollTrusted && !this.exited && !this.retiring && current() + const scrolling = this.outputTail.then((): NativeScrollResult => { + if (!valid()) return refused() + let attempted = false + try { + const plan = this.term.scrollPlan(up, lines, capture) + if (!valid()) return refused() + if (plan.status !== 'wheel') return plan + for (const data of plan.data) { + if (!valid()) return attempted ? uncertain() : refused() + attempted = true + this.proc.write(data) + } + return valid() ? { status: 'input' } : uncertain() + } catch { return attempted ? uncertain() : refused() } + }) + this.outputTail = scrolling.then(() => {}, () => {}) + return scrolling + } + /** * Has the app in this pane requested bracketed paste? Behind `outputTail` for the same reason * `serialize` is: xterm applies writes asynchronously, so a mode set by output we have already @@ -422,6 +454,7 @@ export class HostSession { } dispose(): void { + this.retiring = true this.explicitPauseOwners.clear() this.transportPauseOwners.clear() this.emulatorPauseOwner = false @@ -725,7 +758,7 @@ export class HostSession { if (this.pendingGeometry === pending) this.pendingGeometry = null } ) - this.outputTail = reconciled.catch(() => {}) + this.outputTail = reconciled.catch(() => { this.historyScrollTrusted = false }) return reconciled } diff --git a/src/session-host/sleeping-wake.test.ts b/src/session-host/sleeping-wake.test.ts new file mode 100644 index 0000000000..8a9fead458 --- /dev/null +++ b/src/session-host/sleeping-wake.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest' +import { hostSleepingWake, type SleepingWakeSession } from './sleeping-wake' +import { windowsConsoleOwner } from './windows-pane-owner' + +function fixture() { + const session: SleepingWakeSession = { generation: 'a', exited: false, proc: { pid: 10, write: vi.fn() } } + let current: SleepingWakeSession | undefined = session + const snapshot = { console: [10], processes: [{ pid: 10, parent: 1, executable: 'C:/pwsh.exe', born: 'root-birth' }] } + const probe = vi.fn(async (pid: number, generation: string) => windowsConsoleOwner(pid, generation, snapshot)) + const expected = windowsConsoleOwner(10, 'a', snapshot)! + return { session, snapshot, expected, probe, wake: hostSleepingWake(() => current, probe), + replace: () => { current = { ...session, generation: 'b', proc: { pid: 10, write: vi.fn() } } } } +} +const input = '\x1bcodex resume saved --ask-for-approval on-request\r' + +describe('generation-bound session-host sleeping wake', () => { + it('writes once to the captured shell after a fresh OS generation and birth check', async () => { + const f = fixture() + expect(await f.wake('a', input, f.expected)).toBe(true) + expect(f.probe).toHaveBeenCalledExactlyOnceWith(10, 'a') + expect(f.session.proc.write).toHaveBeenCalledExactlyOnceWith(input) + }) + it('refuses a same-name replacement before or during the final OS probe', async () => { + const f = fixture() + expect(await f.wake('b', input, f.expected)).toBe(false) + expect(f.probe).not.toHaveBeenCalled() + f.probe.mockImplementationOnce(async () => { f.replace(); return f.expected }) + expect(await f.wake('a', input, f.expected)).toBe(false) + expect(f.session.proc.write).not.toHaveBeenCalled() + }) + it('refuses a reused PID, new foreground CLI and ambiguous console', async () => { + const f = fixture() + f.snapshot.processes[0].born = 'new-birth' + expect(await f.wake('a', input, f.expected)).toBe(false) + f.snapshot.processes[0].born = 'root-birth' + f.snapshot.console.push(11) + f.snapshot.processes.push({ pid: 11, parent: 10, executable: 'C:/codex.exe', born: 'root-birth-new' }) + expect(await f.wake('a', input, f.expected)).toBe(false) + f.snapshot.processes.push({ pid: 12, parent: 10, executable: 'C:/other.exe', born: 'root-birth-new' }) + expect(await f.wake('a', input, f.expected)).toBe(false) + expect(f.session.proc.write).not.toHaveBeenCalled() + }) + it('refuses exited, malformed, unobserved and failed writes without a retry', async () => { + const f = fixture() + for (const data of ['', 'cmd', 'cmd\nnext\r', 'cmd\rnext\r', 'cmd\0\r', 'x'.repeat(131105) + '\r']) { + expect(await f.wake('a', data, f.expected)).toBe(false) + } + f.session.exited = true + expect(await f.wake('a', input, f.expected)).toBe(false) + f.session.exited = false + f.probe.mockResolvedValueOnce(null) + expect(await f.wake('a', input, f.expected)).toBe(false) + vi.mocked(f.session.proc.write).mockImplementation(() => { throw new Error('EIO') }) + expect(await f.wake('a', input, f.expected)).toBe(false) + expect(f.session.proc.write).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/session-host/sleeping-wake.ts b/src/session-host/sleeping-wake.ts new file mode 100644 index 0000000000..f97a89e3b3 --- /dev/null +++ b/src/session-host/sleeping-wake.ts @@ -0,0 +1,36 @@ +import type { PaneOwner } from '../shared/agents/pane-owner-predicate' +import { readWindowsConsoleOwner, sameNativeProcess } from './windows-pane-owner' + +export interface SleepingWakeSession { + generation: string + exited: boolean + proc: { pid: number; write(data: string): void } +} + +/** Core already validates the assembled command. This additive host boundary accepts only a + * bounded single submitted line (with its shell-specific line-clearing prefix). */ +export function validSleepingWakeInput(data: unknown): data is string { + return typeof data === 'string' && data.length > 1 && data.length <= 131104 && + data.endsWith('\r') && !/[\r\n\0]/.test(data.slice(0, -1)) +} + +/** Never use the ordinary deferred, name-only write for an offscreen wake. The persistent host + * retains the exact generation across the fresh OS read; a same-PID replacement also needs the + * same process birth time. Older hosts can reject this extension without restarting a shell. */ +export function hostSleepingWake( + lookup: () => SleepingWakeSession | undefined, + probe = readWindowsConsoleOwner +) { + return async (generation: string, data: string, expected: PaneOwner): Promise => { + const session = lookup() + const current = (): boolean => !!session && !session.exited && lookup() === session + if (!session || !current() || generation !== session.generation || !validSleepingWakeInput(data)) return false + const owner = await probe(session.proc.pid, session.generation).catch(() => null) + if (!current() || !sameNativeProcess(expected, owner)) return false + try { + // No await between this final generation/birth check and the write to the captured PTY. + session.proc.write(data) + return true + } catch { return false } + } +} diff --git a/src/session-host/terminal-emulator.ts b/src/session-host/terminal-emulator.ts index 3f777e2538..262ca779cd 100644 --- a/src/session-host/terminal-emulator.ts +++ b/src/session-host/terminal-emulator.ts @@ -1,5 +1,10 @@ import { Terminal, type ITerminalAddon } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' +import { + HISTORY_CAPTURE_MAX_BYTES, HISTORY_CAPTURE_MAX_ROWS, HISTORY_PAGE_MAX_ROWS, + type HistoryScrollCapture, type HistoryScrollRow, type NativeScrollPlan +} from '../shared/history-scroll' +import { mouseEncodingRestore, mouseState, wheelReport } from './emulator-mouse' // @xterm/addon-serialize's published types are written against @xterm/xterm's (browser) Terminal, // which has a superset of DOM-specific members @xterm/headless's Terminal lacks. The addon only @@ -18,14 +23,10 @@ import { SerializeAddon } from '@xterm/addon-serialize' * DEC private modes as part of its `serialize()` output (bracketed paste, application cursor * keys, origin mode, insert mode, reverse-wraparound, send-focus, wraparound, and — when the * active buffer is the alternate screen — the `?1049h` switch). Read directly from the compiled - * `_serializeModes()` in `node_modules/@xterm/addon-serialize/lib/addon-serialize.js` rather than - * assumed from the docs. It does NOT emit `CSI ?1006h` (SGR extended mouse coordinates) — there - * is no separate field on the public `IModes` API for it to read; `modes.mouseTrackingMode` only - * says which tracking PROTOCOL is on (x10/vt200/drag/any), not the coordinate ENCODING. This app - * always turns mouse tracking on together with SGR (`CO_ATTACH_MOUSE_SEQ` in the renderer mirrors - * real tmux's `mouse on`, which always pairs `?1000h`/`?1002h` with `?1006h`), so that one - * sequence is appended by hand below whenever tracking is active. If a future xterm.js version - * starts emitting it itself, appending it twice is a harmless idempotent DECSET, not a bug. + * `_serializeModes()` in the installed addon's primary source rather than assumed from docs. + * It does not restore coordinate ENCODING: public modes expose only the tracking PROTOCOL. + * Native applications can independently request DEFAULT, SGR or SGR_PIXELS, even while tracking + * is off. The guarded headless mouse adapter restores that actual state without assuming tmux. */ export class TerminalEmulator { private readonly term: Terminal @@ -62,16 +63,85 @@ export class TerminalEmulator { * The reconstructed screen: serialized content (scrollback capped at `scrollback`, defaulting * to this emulator's construction-time cap — smaller for `captureSession`'s "recent lines" * callers) plus the mode-restore/alt-buffer/cursor tail `SerializeAddon` already produces, with - * the SGR-mouse gap above patched in by hand. Returns '' for a session that has never painted + * the actual coordinate encoding restored. Returns '' for a session that has never painted * anything (a cold session moments after spawn) — callers treat '' the same way the rest of * this app already treats an empty tmux capture: "nothing to seed", never a screen reset. */ serialize(scrollback = this.defaultScrollback): string { - let out = this.serializer.serialize({ scrollback: Math.max(0, scrollback) }) - if (this.term.modes.mouseTrackingMode !== 'none' && !out.includes('\x1b[?1006h')) { - out += '\x1b[?1006h' + const out = this.serializer.serialize({ scrollback: Math.max(0, scrollback) }) + const state = mouseState(this.term) + if (!state) throw new Error('Unsupported terminal mouse state') + if (!out && state.encoding === 'DEFAULT') return '' + return out + mouseEncodingRestore(state) + } + + /** Called synchronously only after the owning backend's output/geometry barrier. */ + scrollPlan(up: boolean, lines: number, capture: boolean): NativeScrollPlan { + if (typeof up !== 'boolean' || typeof capture !== 'boolean' || + !Number.isInteger(lines) || lines < 1 || lines > 20 || + !Number.isInteger(this.term.cols) || this.term.cols < 1 || this.term.cols > 65_535 || + !Number.isInteger(this.term.rows) || this.term.rows < 1) { + return { status: 'refused', message: 'Invalid terminal scroll request.' } + } + const state = mouseState(this.term) + if (!state) return { status: 'refused', message: 'This terminal does not support native scrolling.' } + if (state.protocol === 'NONE' || state.protocol === 'X10') { + if (!capture) return { status: 'history' } + const snapshot = this.historyCapture() + return snapshot ? { status: 'history', capture: snapshot } : + { status: 'refused', message: 'This terminal history exceeds the capture size limit.' } + } + const report = wheelReport(state, up) + if (!report) return { status: 'refused', message: 'This terminal does not support native scrolling.' } + return { status: 'wheel', data: Array.from({ length: lines }, () => report) } + } + + private historyCapture(): HistoryScrollCapture | undefined { + const capture: HistoryScrollCapture = { cols: this.term.cols, + viewportRows: Math.min(HISTORY_PAGE_MAX_ROWS, this.term.rows), rows: [], olderTruncated: false } + // Count JSON bytes, including escaped controls/backslashes, row metadata and separators. + let bytes = Buffer.byteLength(JSON.stringify(capture), 'utf8') + const newest: HistoryScrollRow[] = [] + const buffers = [this.term.buffer.normal] + if (this.term.buffer.active.type === 'alternate') buffers.push(this.term.buffer.alternate) + for (let b = buffers.length - 1; b >= 0; b--) { + const buffer = buffers[b] + for (let i = buffer.length - 1; i >= 0; i--) { + const line = buffer.getLine(i) + if (!line) continue + const row: HistoryScrollRow = { text: line.translateToString(true), isWrapped: line.isWrapped, + section: b === 0 ? 'normal' : 'alternate' } + const added = Buffer.byteLength(JSON.stringify(row), 'utf8') + 1 + if (newest.length >= HISTORY_CAPTURE_MAX_ROWS || bytes + added > HISTORY_CAPTURE_MAX_BYTES) { + // A combining-character cell can exceed the budget even in a narrow grid. + // Never misrepresent its newest physical row as an empty captured history. + if (!newest.length) return undefined + capture.olderTruncated = true + capture.rows = newest.reverse() + return capture + } + newest.push(row); bytes += added + } + } + capture.rows = newest.reverse() + return capture + } + + /** Plain retained normal history plus the current alternate screen, joining soft wraps. */ + historyText(): string { + const lines: string[] = [] + const append = (buffer: typeof this.term.buffer.normal): void => { + for (let i = 0; i < buffer.length; i++) { + const row = buffer.getLine(i) + if (!row) continue + const text = row.translateToString(true) + if (row.isWrapped && lines.length) lines[lines.length - 1] += text + else lines.push(text) + } } - return out + append(this.term.buffer.normal) + if (this.term.buffer.active.type === 'alternate') append(this.term.buffer.alternate) + return lines.join('\n') } /** diff --git a/src/session-host/terminal-history-snapshot.test.ts b/src/session-host/terminal-history-snapshot.test.ts new file mode 100644 index 0000000000..122b090ddb --- /dev/null +++ b/src/session-host/terminal-history-snapshot.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { TerminalEmulator } from './terminal-emulator' + +describe('retained backend history text', () => { + it('reads actual rendered cursor edits, colors, Unicode and joined soft wraps', async () => { + const screen = new TerminalEmulator({ cols: 12, rows: 3, scrollback: 1000 }) + try { + await screen.write('old needle\r\n' + 'row\r\n'.repeat(30) + '\x1b[31mΩ 😀\x1b[0m\r\nabcdefghijklmno\r\nwrong\r\x1b[2Kright') + const text = screen.historyText() + expect(text).toContain('old needle') + expect(text).toContain('Ω 😀') + expect(text).toContain('abcdefghijklmno') + expect(text).toContain('right') + expect(text).not.toContain('wrong') + expect(text).not.toContain('\x1b') + } finally { screen.dispose() } + }) + it('includes retained normal history while an alternate-screen app is active', async () => { + const screen = new TerminalEmulator({ cols: 80, rows: 3, scrollback: 1000 }) + try { + await screen.write('older normal\r\n' + 'row\r\n'.repeat(10) + '\x1b[?1049hcurrent alternate') + expect(screen.historyText()).toContain('older normal') + expect(screen.historyText()).toContain('current alternate') + } finally { screen.dispose() } + }) +}) diff --git a/src/session-host/terminal-scroll.test.ts b/src/session-host/terminal-scroll.test.ts new file mode 100644 index 0000000000..afd9f66206 --- /dev/null +++ b/src/session-host/terminal-scroll.test.ts @@ -0,0 +1,188 @@ +import { Terminal } from '@xterm/headless' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HISTORY_CAPTURE_MAX_BYTES, HISTORY_CAPTURE_MAX_ROWS, type HistoryScrollCapture } from '../shared/history-scroll' +import { TerminalEmulator } from './terminal-emulator' + +const screens: TerminalEmulator[] = [] +const terminals: Terminal[] = [] +const screen = (cols = 16, rows = 3, scrollback = 1000): TerminalEmulator => { + const term = new TerminalEmulator({ cols, rows, scrollback }); screens.push(term); return term +} +const inner = (term: TerminalEmulator): Terminal => (term as unknown as { term: Terminal }).term +interface CoreMouse { + activeEncoding: string + activeProtocol: string + triggerMouseEvent(e: { col: number; row: number; x: number; y: number; button: number; action: number; ctrl: boolean; alt: boolean; shift: boolean }): boolean + addEncoding(name: string, encoder: () => string): void + _encodings: Record +} +const coreMouse = (term: Terminal): CoreMouse => + (term as unknown as { _core: { coreMouseService: CoreMouse } })._core.coreMouseService +async function put(term: Terminal, data: string): Promise { + await new Promise((resolve) => term.write(data, resolve)) +} +function capture(term: TerminalEmulator): HistoryScrollCapture { + const result = term.scrollPlan(true, 1, true) + expect(result.status).toBe('history') + if (result.status !== 'history' || !result.capture) throw new Error('Expected captured history') + return result.capture +} +afterEach(() => { + screens.splice(0).forEach((s) => s.dispose()) + terminals.splice(0).forEach((t) => t.dispose()) + vi.restoreAllMocks() +}) + +describe('native emulator scroll decisions', () => { + it('returns history without emitting input when tracking is off or X10 disallows wheel', async () => { + const s = screen(); const received: string[] = [] + inner(s).onData((data) => received.push(data)); inner(s).onBinary((data) => received.push(data)) + await s.write('pre-attach old\r\n' + 'tail\r\n'.repeat(12)) + expect(s.scrollPlan(true, 20, false)).toEqual({ status: 'history' }) + expect(capture(s).rows.some((row) => row.text === 'pre-attach old')).toBe(true) + await s.write('\x1b[?9h\x1b[?1006h') + expect(inner(s).modes.mouseTrackingMode).toBe('x10') + expect(s.scrollPlan(false, 2, false)).toEqual({ status: 'history' }) + expect(received).toEqual([]) + }) + + it.each([ + ['VT200', '\x1b[?1000h', '', '\x1b[M`!!', '\x1b[Ma!!'], + ['DRAG SGR', '\x1b[?1002h', '\x1b[?1006h', '\x1b[<64;1;1M', '\x1b[<65;1;1M'], + ['ANY pixels', '\x1b[?1003h', '\x1b[?1016h', '\x1b[<64;0;0M', '\x1b[<65;0;0M'] + ])('uses the actual %s requested encoder, matching genuine xterm wheel reports without emitter side effects', async (_name, protocol, encoding, up, down) => { + const s = screen(); const actual = new Terminal({ cols: 16, rows: 3, allowProposedApi: true }); terminals.push(actual) + await s.write(protocol + encoding); await put(actual, protocol + encoding) + const reference: string[] = []; const sideEffects: string[] = [] + actual.onData((data) => reference.push(data)); actual.onBinary((data) => reference.push(data)) + inner(s).onData((data) => sideEffects.push(data)); inner(s).onBinary((data) => sideEffects.push(data)) + for (const [isUp, expected] of [[true, up], [false, down]] as const) { + expect(coreMouse(actual).triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: 4, + action: isUp ? 0 : 1, ctrl: false, alt: false, shift: false })).toBe(true) + expect(reference.at(-1)).toBe(expected) + expect(s.scrollPlan(isUp, 3, true)).toEqual({ status: 'wheel', data: [expected, expected, expected] }) + } + expect(sideEffects).toEqual([]) + }) + + it('reads each applied tracking/encoding transition rather than caching prior mouse mode', async () => { + const s = screen() + await s.write('\x1b[?1000h\x1b[?1006h') + expect(s.scrollPlan(true, 1, false)).toEqual({ status: 'wheel', data: ['\x1b[<64;1;1M'] }) + await s.write('\x1b[?1006l') + expect(s.scrollPlan(false, 1, false)).toEqual({ status: 'wheel', data: ['\x1b[Ma!!'] }) + await s.write('\x1b[?1000l') + expect(s.scrollPlan(true, 1, false)).toEqual({ status: 'history' }) + await s.write('\x1b[?1016h\x1b[?1000h') + expect(s.scrollPlan(true, 1, false)).toEqual({ status: 'wheel', data: ['\x1b[<64;0;0M'] }) + }) + + it.each([0, -1, 21, 1.5, Number.NaN, Number.POSITIVE_INFINITY])('refuses invalid notch count %s before encoding', async (lines) => { + const s = screen(); await s.write('\x1b[?1000h') + expect(s.scrollPlan(true, lines, true).status).toBe('refused') + }) + + it('fails closed for unsupported encoding, missing private shape, empty/throwing encoder and bad geometry', async () => { + const s = screen(); await s.write('\x1b[?1000h') + const mouse = coreMouse(inner(s)); const saved = mouse._encodings.DEFAULT + mouse.addEncoding('CUSTOM', () => 'unsafe'); mouse.activeEncoding = 'CUSTOM' + expect(s.scrollPlan(true, 1, true).status).toBe('refused') + expect(() => s.serialize()).toThrow('Unsupported terminal mouse state') + mouse.activeEncoding = 'DEFAULT'; mouse._encodings.DEFAULT = undefined + expect(s.scrollPlan(true, 1, true).status).toBe('refused') + mouse._encodings.DEFAULT = () => '' + expect(s.scrollPlan(true, 1, true).status).toBe('refused') + mouse._encodings.DEFAULT = () => { throw new Error('unsupported encoder') } + expect(s.scrollPlan(true, 1, true).status).toBe('refused') + mouse._encodings.DEFAULT = () => 'x'.repeat(65) + expect(s.scrollPlan(true, 1, true).status).toBe('refused') + mouse._encodings.DEFAULT = saved + const core = (inner(s) as unknown as { _core: { coreMouseService?: CoreMouse } })._core + const original = core.coreMouseService; core.coreMouseService = undefined + expect(s.scrollPlan(true, 1, true).status).toBe('refused'); core.coreMouseService = original + vi.spyOn(inner(s), 'cols', 'get').mockReturnValue(65_536) + expect(s.scrollPlan(true, 1, true).status).toBe('refused') + }) +}) + +describe('physical native history capture', () => { + it('preserves translated Unicode, soft wraps and normal/alternate section boundaries', async () => { + const s = screen(6, 3) + await s.write('abcdefghij\r\n\x1b[31mΩ 😀\x1b[0m\r\n' + 'line\r\n'.repeat(8)) + await s.write('\x1b[?1049hALTNOW') + const c = capture(s) + expect(c.cols).toBe(6); expect(c.viewportRows).toBe(3); expect(c.olderTruncated).toBe(false) + expect(c.rows).toContainEqual({ text: 'abcdef', isWrapped: false, section: 'normal' }) + expect(c.rows).toContainEqual({ text: 'ghij', isWrapped: true, section: 'normal' }) + expect(c.rows.some((r) => r.text.includes('Ω 😀') && r.section === 'normal')).toBe(true) + expect(c.rows).toContainEqual({ text: 'ALTNOW', isWrapped: false, section: 'alternate' }) + expect(c.rows.map((r) => r.text).join('')).not.toContain('\x1b') + await s.write('\x1b[?1049l') + expect(capture(s).rows.every((r) => r.section === 'normal')).toBe(true) + expect(s.scrollPlan(true, 1, false)).toEqual({ status: 'history' }) + }) + + it('keeps only the latest 8192 retained physical rows and marks removed older rows', async () => { + const s = screen(16, 3, 12_000) + await s.write(Array.from({ length: 9000 }, (_, i) => 'M'+String(i).padStart(4, '0')).join('\r\n')) + const c = capture(s) + expect(c.rows).toHaveLength(HISTORY_CAPTURE_MAX_ROWS) + expect(c.olderTruncated).toBe(true) + expect(c.rows.at(-1)?.text).toBe('M8999') + expect(c.rows.some((r) => r.text === 'M0000')).toBe(false) + }) + + it('bounds the full escaped UTF-8 JSON body to 1 MiB while retaining newest complete rows', async () => { + const s = screen(1900, 2, 400) + await s.write(('\\'.repeat(1800)+'\r\n').repeat(350)+'LAST Ω') + const c = capture(s) + expect(Buffer.byteLength(JSON.stringify(c), 'utf8')).toBeLessThanOrEqual(HISTORY_CAPTURE_MAX_BYTES) + expect(c.olderTruncated).toBe(true) + expect(c.rows.at(-1)?.text).toBe('LAST Ω') + expect(c.rows.length).toBeGreaterThan(1) + expect(c.rows.length).toBeLessThan(351) + }) + + it('bounds viewport height while preserving actual buffer rows and has no capture work when capture is false', () => { + const s = screen(12, 250) + expect(capture(s).viewportRows).toBe(200) + expect(capture(s).rows).toHaveLength(250) + const spy = vi.spyOn(inner(s).buffer.normal, 'getLine') + expect(s.scrollPlan(true, 1, false)).toEqual({ status: 'history' }) + expect(spy).not.toHaveBeenCalled() + }) + + it('refuses an oversized newest combining-character row rather than fabricating empty history', async () => { + const s = screen(2, 1, 0) + await s.write('x'+'\u0301'.repeat(600_000)) + expect(s.scrollPlan(true, 1, true).status).toBe('refused') + expect(s.scrollPlan(true, 1, false)).toEqual({ status: 'history' }) + }) +}) + +describe('snapshot coordinate encoding restoration', () => { + it('preserves truly cold empty snapshots', () => { + expect(screen().serialize()).toBe('') + }) + + it('restores a mode-only encoding request before any screen text', async () => { + const source = screen(); await source.write('\x1b[?1016h') + const fresh = screen(); await fresh.write(source.serialize()) + expect(coreMouse(inner(fresh)).activeEncoding).toBe('SGR_PIXELS') + expect(inner(fresh).modes.mouseTrackingMode).toBe('none') + }) + + it.each([ + ['DEFAULT', '\x1b[?1000h', '\x1b[?1016h'], + ['SGR', '\x1b[?1000h\x1b[?1006h', '\x1b[?1016h'], + ['SGR_PIXELS', '\x1b[?1000h\x1b[?1016h', '\x1b[?1006h'], + ['SGR with tracking off', '\x1b[?1006h', '\x1b[?1016h'], + ['pixels with tracking off', '\x1b[?1016h', '\x1b[?1006h'] + ])('restores actual %s encoding without assuming active mouse implies SGR', async (_name, modes, oldModes) => { + const source = screen(); await source.write('seed\r\n'+modes) + const fresh = screen(); await fresh.write(oldModes) + await fresh.write(source.serialize()) + expect(coreMouse(inner(fresh)).activeEncoding).toBe(coreMouse(inner(source)).activeEncoding) + expect(inner(fresh).modes.mouseTrackingMode).toBe(inner(source).modes.mouseTrackingMode) + }) +}) diff --git a/src/shared/agents/codex-approval-values.ts b/src/shared/agents/codex-approval-values.ts new file mode 100644 index 0000000000..12290246b0 --- /dev/null +++ b/src/shared/agents/codex-approval-values.ts @@ -0,0 +1,75 @@ +// Shared pure parser for the host capability probe and companion interop fixture. +/** + * Only the option we are reading, never the one next to it. + * + * `--help` lists `-s, --sandbox ` with its own `[possible values: read-only, + * workspace-write, danger-full-access]` two lines above `--ask-for-approval`, so a parser that + * scanned the page for "possible values" would confidently return the SANDBOX vocabulary and then + * emit `--ask-for-approval read-only`. The slice below is anchored on the option's own header line + * and ends at the next one. + */ +const ASK_FOR_APPROVAL = /--ask-for-approval\b/ +/** An option header: indented at most 6 and starting with a dash. Value/description lines inside an + * option are indented 10, so this cannot cut a block short. */ +const OPTION_HEADER = /^ {0,6}-{1,2}[A-Za-z]/ + +/** + * Pure: `codex --help` output → the `--ask-for-approval` values it advertises, or `null` when the + * page did not say (absent output, an option we could not find, a shape we do not recognise). + * + * `null` is a first-class answer and NOT an empty list: "the CLI accepts nothing" and "we could not + * read the page" lead to different decisions one layer up, where `null` resolves to the baseline + * vocabulary and an empty list would forbid every value including the two that have always worked. + * + * BOTH of clap's renderings are handled, because both are real. Long `--help` prints a block: + * + * -a, --ask-for-approval + * Configure when the model requires human approval before executing a command + * + * Possible values: + * - on-request: The model decides when to ask the user for approval + * - never: Never ask for user approval Execution failures are immediately + * returned to the model + * + * short `-h` prints it inline and wraps it mid-phrase: + * + * -a, --ask-for-approval + * Configure when the model requires human approval ... [possible + * values: on-request, never] + * + * — which is why the inline branch joins the slice before matching rather than working line by + * line. The probe below asks for the long form; the short form is handled so a caller that has + * `-h` output lying around gets the same answer instead of a silent `null`. + * + * Every token is validated against `[a-z][a-z0-9-]*`. These strings are appended to a launch + * command that is typed into a tmux pane, so the help page is untrusted input at an interpolation + * site like any other — a token we would not recognise is dropped rather than quoted and hoped + * for, and if nothing survives the answer is `null`. + */ +export function codexApprovalValuesFrom(helpOutput: string | null | undefined): string[] | null { + if (!helpOutput) return null + const lines = helpOutput.split(/\r?\n/) + const start = lines.findIndex((l) => ASK_FOR_APPROVAL.test(l)) + if (start < 0) return null + let end = start + 1 + while (end < lines.length && !OPTION_HEADER.test(lines[end])) end++ + const slice = lines.slice(start, end) + + const values: string[] = [] + const inline = slice.join(' ').match(/\[possible\s+values:\s*([^\]]*)\]/i) + if (inline) { + values.push(...inline[1].split(',')) + } else { + // The block form. Anchored on the `Possible values:` marker so a bullet inside the option's + // prose description can never be mistaken for a value. + const marker = slice.findIndex((l) => /^\s*possible\s+values:\s*$/i.test(l)) + if (marker < 0) return null + for (const line of slice.slice(marker + 1)) { + const m = line.match(/^\s*-\s+([^\s:]+)\s*:/) + if (m) values.push(m[1]) + } + } + const clean = values.map((v) => v.trim()).filter((v) => /^[a-z][a-z0-9-]*$/.test(v)) + return clean.length ? Array.from(new Set(clean)) : null +} + diff --git a/src/shared/agents/normalize.ts b/src/shared/agents/normalize.ts index 413f12049d..e5fcd615b3 100644 --- a/src/shared/agents/normalize.ts +++ b/src/shared/agents/normalize.ts @@ -1,5 +1,6 @@ import type { AgentId } from './config' import type { ObservedClaudeAccount } from '../types' +import { permissionSuggestions, type PermissionSuggestion } from '../hook-answers' import { ASK_USER_QUESTION_TOOL, isSafeToolName, readQuestions, type HeldPermission } from './permission-answer' import { isClaudeAgentId, @@ -79,6 +80,10 @@ export interface NormalizedAgentEvent { // `nodeterm_pending_id` (merged into the payload by the hook server) so the phone/canvas can // answer the held hook. Absent = no held hook (legacy prompt path). See docs/hook-reply-approvals.md. pendingId?: string + /** v2 held AskUserQuestion ticket, distinct from a permission approval. */ + questionPendingId?: string + /** Concrete rule scopes supplied by the exact held PermissionRequest. */ + permissionSuggestions?: PermissionSuggestion[] // needs-you (blocked/waiting) only: how the shell classified this ask AFTER the mirror's // stash-priority reclassification (see agent-status-mirror.recordAgentEvent). 'question' = an // AskUserQuestion picker (its `pendingId` is stripped — approve/deny on a question is wrong UX); @@ -221,6 +226,7 @@ interface ClaudePayload { /** Deterministic-approval ticket the managed hook script added to its POST body and the hook * server merged into this payload (PermissionRequest only). */ nodeterm_pending_id?: string + nodeterm_hook_reply?: number /** Deterministic-approval "answered" signal: the managed hook fired a second POST the instant it * read a valid allow/deny answer file, tagged nodeterm_answered=, merged into this * payload by the hook server. It rides alongside the original PermissionRequest payload, so it is @@ -292,7 +298,9 @@ export function normalizeClaude(env: RawHookEnvelope): NormalizedAgentEvent | nu ...base, kind: 'state', state: 'working', - ...(p.nodeterm_pending_id ? { pendingId: p.nodeterm_pending_id } : {}) + ...(p.tool_name === 'AskUserQuestion' && p.hook_event_name === 'PreToolUse' + ? { answeredQuestionId: p.tool_use_id } + : p.nodeterm_pending_id ? { pendingId: p.nodeterm_pending_id } : {}) } } const ev = p.hook_event_name @@ -324,7 +332,8 @@ export function normalizeClaude(env: RawHookEnvelope): NormalizedAgentEvent | nu if (ev === 'PreToolUse' || ev === 'PostToolUse') { if (tool === 'AskUserQuestion' && p.tool_use_id) { return ev === 'PreToolUse' - ? { ...base, kind: 'state', state: 'waiting', questionId: p.tool_use_id } + ? { ...base, kind: 'state', state: 'waiting', questionId: p.tool_use_id, + ...(p.nodeterm_hook_reply === 2 && p.nodeterm_pending_id ? { questionPendingId: p.nodeterm_pending_id } : {}) } : { ...base, kind: 'state', state: 'working', answeredQuestionId: p.tool_use_id } } if (SUBAGENT_TOOLS.has(tool)) { @@ -445,6 +454,7 @@ export function normalizeClaude(env: RawHookEnvelope): NormalizedAgentEvent | nu lastMessage: p.last_assistant_message, // Deterministic-approval ticket (present only when the wait-branch of the managed hook ran). ...(p.nodeterm_pending_id ? { pendingId: p.nodeterm_pending_id } : {}), + ...(p.nodeterm_hook_reply === 2 && p.nodeterm_pending_id ? { permissionSuggestions: permissionSuggestions(p) } : {}), ...(p.nodeterm_pending_id && isSafeToolName(tool) ? { held: heldOf(p.nodeterm_pending_id, tool, p) } : {}) } } diff --git a/src/shared/agents/permission-answer.ts b/src/shared/agents/permission-answer.ts index 133295e74c..6e4dc47e54 100644 --- a/src/shared/agents/permission-answer.ts +++ b/src/shared/agents/permission-answer.ts @@ -54,7 +54,9 @@ export const ANSWER_TEXT_MAX_CHARS = 8000 export interface AnswerPermissionPayload { nodeId: string pendingId: string - decision?: 'allow' | 'deny' + decision?: 'allow' | 'deny' | 'allow-always' + /** Index of an exact rule advertised by this request (Android / legacy canvas). */ + suggestionIndex?: number answer?: PermissionAnswer } diff --git a/src/shared/agents/sleeping-wake.test.ts b/src/shared/agents/sleeping-wake.test.ts new file mode 100644 index 0000000000..d409b150d5 --- /dev/null +++ b/src/shared/agents/sleeping-wake.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest' +import { deliverSleepingWake, type SleepingWakeRequest } from './sleeping-wake' +import type { PaneOwner } from './pane-owner-predicate' + +const shell: PaneOwner = { command: 'bash', panePid: 41, paneId: '%7', tty: '/dev/pts/1', argv: ['bash -i'], pids: [41] } +const request: SleepingWakeRequest = { nodeId: 'term-one', agentId: 'codex', command: 'codex resume sid --ask-for-approval never', exitedByUs: true, + recorded: { command: 'bash', panePid: 41, paneId: '%7' } } +function setup(owner: PaneOwner | null = shell) { + return { owned: vi.fn(() => true), owner: vi.fn(async () => owner), write: vi.fn(async (_data: string, _owner: PaneOwner) => true), platform: 'linux' } +} + +describe('owned sleeping wake delivery', () => { + it('clears the confirmed shell and submits exactly one current command', async () => { + const io = setup() + expect(await deliverSleepingWake(request, io)).toEqual({ delivered: true, verdict: 'resume' }) + expect(io.write).toHaveBeenCalledExactlyOnceWith('\x15codex resume sid --ask-for-approval never\r', shell) + }) + it('requires ownership before reading and fences replacement across the async owner read', async () => { + const io = setup() + io.owned.mockReturnValue(false) + expect((await deliverSleepingWake(request, io)).verdict).toBe('unreadable') + expect(io.owner).not.toHaveBeenCalled() + io.owned.mockReturnValueOnce(true).mockReturnValue(false) + expect((await deliverSleepingWake(request, io)).verdict).toBe('context-changed') + expect(io.write).not.toHaveBeenCalled() + }) + it('rejects a running CLI, recycled pane, changed foreground, lost proof and unreadable host', async () => { + const cases: Array<[PaneOwner | null, SleepingWakeRequest, string]> = [ + [{ ...shell, command: 'node', argv: ['node /bin/codex resume sid'] }, request, 'agent-running'], + [{ ...shell, paneId: '%8' }, request, 'context-changed'], + [{ ...shell, panePid: 42 }, request, 'context-changed'], + [{ ...shell, command: 'ssh', argv: ['ssh elsewhere'] }, request, 'context-changed'], + [shell, { ...request, recorded: null }, 'no-proof'], + [null, request, 'unreadable'] + ] + for (const [owner, req, verdict] of cases) { + const io = setup(owner) + expect((await deliverSleepingWake(req, io)).verdict).toBe(verdict) + expect(io.write).not.toHaveBeenCalled() + } + }) + it('explicit deep pause accepts only a confirmed shell and keeps Windows shell clearing distinct', async () => { + const io = setup({ ...shell, command: 'pwsh', argv: ['pwsh'] }) + // Deep pause has no prior pane proof; the existing desktop gate recognizes POSIX shells. + expect((await deliverSleepingWake({ ...request, exitedByUs: false, recorded: null }, io)).delivered).toBe(false) + const posix = setup() + expect((await deliverSleepingWake({ ...request, exitedByUs: false, recorded: null }, posix)).delivered).toBe(true) + const windows = setup({ ...shell, command: 'pwsh', argv: ['pwsh'] }) + windows.platform = 'win32' + expect((await deliverSleepingWake({ ...request, recorded: { ...request.recorded!, command: 'pwsh' } }, windows)).delivered).toBe(true) + expect(windows.write.mock.calls[0][0]).toMatch(/^\x1b/) + }) + it('bounds a wedged owner read before any input and contains probe failure', async () => { + vi.useFakeTimers() + try { + const io = setup(); io.owner.mockImplementation(() => new Promise(() => {})) + const pending = deliverSleepingWake(request, io) + await vi.advanceTimersByTimeAsync(6000) + expect(await pending).toEqual({ delivered: false, verdict: 'unreadable' }) + expect(io.write).not.toHaveBeenCalled() + io.owner.mockRejectedValue(new Error('disconnected')) + expect((await deliverSleepingWake(request, io)).verdict).toBe('unreadable') + } finally { vi.useRealTimers() } + }) + it('rejects a malformed command before any pane read and reports uncertain write without retry', async () => { + for (const command of ['', 'codex\nrm bad', 'codex\r', 'codex\x00']) { + const io = setup() + expect((await deliverSleepingWake({ ...request, command }, io)).verdict).toBe('invalid-request') + expect(io.owner).not.toHaveBeenCalled() + } + const io = setup() + io.write.mockResolvedValue(false) + expect(await deliverSleepingWake(request, io)).toEqual({ delivered: false, verdict: 'delivery-failed' }) + expect(io.write).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/shared/agents/sleeping-wake.ts b/src/shared/agents/sleeping-wake.ts new file mode 100644 index 0000000000..9413b315f3 --- /dev/null +++ b/src/shared/agents/sleeping-wake.ts @@ -0,0 +1,49 @@ +import type { PaneOwner } from './pane-owner-predicate' +import { decideWakeResume, type WakeContext, type WakeVerdict } from './wake-identity' +import { shellKillLineSequence } from '../shell-kill-line' + +/** Internal renderer → owning core request. No attach, spawn, or caller-supplied SSH descriptor. */ +export interface SleepingWakeRequest { + nodeId: string + agentId: string + command: string + recorded?: WakeContext | null + exitedByUs: boolean +} +export interface SleepingWakeResult { + delivered: boolean + verdict: WakeVerdict | 'invalid-request' | 'delivery-failed' +} + +/** The ownership check must fence the same live/released generation before and after every await. */ +export async function deliverSleepingWake( + request: SleepingWakeRequest, + io: { + owned(): boolean + owner(): Promise + write(data: string, owner: PaneOwner): Promise + binaries?: readonly string[] | null + platform?: string + } +): Promise { + if (!request || typeof request.nodeId !== 'string' || !request.nodeId || request.nodeId.length > 256 || + typeof request.agentId !== 'string' || !request.agentId || request.agentId.length > 256 || + typeof request.command !== 'string' || !request.command.trim() || request.command.length > 131072 || + /[\r\n\x00]/.test(request.command) || typeof request.exitedByUs !== 'boolean') { + return { delivered: false, verdict: 'invalid-request' } + } + if (!io.owned()) return { delivered: false, verdict: 'unreadable' } + let timer: ReturnType | undefined + const owner = await Promise.race([ + Promise.resolve().then(() => io.owner()).catch(() => null), + new Promise((resolve) => { timer = setTimeout(() => resolve(null), 6000) }) + ]).finally(() => { if (timer) clearTimeout(timer) }) + if (!io.owned()) return { delivered: false, verdict: 'context-changed' } + const verdict = decideWakeResume({ owner, recorded: request.recorded, exitedByUs: request.exitedByUs, + agentId: request.agentId, binaries: io.binaries }) + if (verdict !== 'resume' || !owner) return { delivered: false, verdict } + const killLine = shellKillLineSequence(undefined, owner.command, io.platform) + // One delivery, never a retry after an uncertain write. The saved shell already owns cwd/account. + const delivered = await io.write(killLine + request.command + '\r', owner) + return { delivered, verdict: delivered ? 'resume' : 'delivery-failed' } +} diff --git a/src/shared/agents/wake-identity.ts b/src/shared/agents/wake-identity.ts new file mode 100644 index 0000000000..4ec94f812f --- /dev/null +++ b/src/shared/agents/wake-identity.ts @@ -0,0 +1,172 @@ +/** + * WHICH PANE MAY BE TYPED INTO — the identity behind Eco's exit and its much later wake. + * + * Hibernation is two writes separated by hours: `/exit` now, `claude --resume ` when the user + * comes back. Both land in a tmux pane the user can also type into, and the second one carries a + * session id that only resolves in the context the first one quit. So the pair needs an identity, + * and the question it has to answer is NOT "is something shell-shaped in this pane?" but "is this + * the shell we exited the CLI in?". + * + * ── WHY THE OLD GATE COULD NOT ANSWER IT (issue #823, measured on tmux 3.4) ───────────────────── + * + * The wake's gate was `isShellCommand(pane) || pane === hibernatedPane` — a NAME test. For a node + * whose agent is reached over an interactive `ssh` inside its pane, the names read like this + * (measured, a real pane, real ssh, real `ps`): + * + * local shell idle pane_current_command = bash fg argv = `bash …` + * ssh up, agent on far side pane_current_command = ssh fg argv = `ssh -i … localhost` + * ssh dead, login shell back pane_current_command = bash fg argv = `bash …` + * + * `pane_pid` and `pane_id` are IDENTICAL in all three readings — the pane's root process is the + * local login shell throughout, and the remote shell was never a local process at all. So after the + * fact NOTHING about the pane distinguishes "the CLI was here" from "the CLI was at the far end of + * an ssh that has since died": both present a plain local shell. The old gate passed, and the + * resume was typed into the Mac's login shell at `~`, where the remote session id cannot resolve. + * + * ── WHERE THE IDENTITY ACTUALLY LIVES ─────────────────────────────────────────────────────────── + * + * The difference is only visible while the CLI is still running, so the proof has to be taken THEN. + * `decideHibernateExit` refuses to quit a pane unless the kernel says the agent binary owns its + * foreground process group (`isAgentPane` — the same positive, three-valued predicate the delivery + * gate uses). An `ssh` pane answers `not-agent`, and so does a pane that is already sitting on a + * shell because the CLI is long gone. Neither is exited, so neither ever becomes SLEEPING, and + * there is nothing for a later wake to get wrong. + * + * `decideWakeResume` is then the cheap re-check across the gap: the same pane, still not an agent's, + * still recognisable. It cannot re-derive the proof — that moment has passed — so a record without + * one is refused rather than guessed at. That is deliberately the migration path too: a node + * hibernated by the build that shipped this bug carries no proof, and refusing it is the only + * honest answer (the node stays SLEEPING and its chip stays clickable). + */ +import { isShellCommand } from '@shared/agents/pane' +import { isAgentPane, type PaneOwner } from '@shared/agents/pane-owner-predicate' + +/** + * What a pane looked like at the instant our `/exit` was answered — the proof a wake re-checks. + * + * `panePid` and `paneId` cannot tell two SHELLS apart (see the header measurement), and they are + * not here for that. They pin the PANE: a session recycled under the node, or a node respawned onto + * a new one, changes both, and a resume aimed at a record from the pane before it would be landing + * somewhere it was never authorised for. `command` carries the allowlist-free recognition the exit + * half already relies on, so a `nu` / `xonsh` / `pwsh` user is not hibernated and then never woken. + */ +export interface WakeContext { + /** `#{pane_current_command}` once the CLI had let go. */ + command: string + /** `#{pane_pid}` — the process tmux forked for the pane. Constant for the pane's life. */ + panePid: number + /** `#{pane_id}` (`%17`) — never reused for the life of the tmux server. Absent on an older read. */ + paneId?: string +} + +/** + * `'agent-owns-pane'` is the ONLY outcome that may be followed by a write. + * + * `'not-in-this-pane'` is terminal for this node until something changes: the kernel answered and + * the agent is not in the pane's foreground group. `'unreadable'` is transient (no tmux, a pane + * that stopped existing, `ps` unavailable, the deadline lapsed) and the next sweep re-asks — it is + * never upgraded, because the exit it gates is irreversible. + */ +export type ExitContextVerdict = 'agent-owns-pane' | 'not-in-this-pane' | 'unreadable' + +/** + * May we ask the CLI in this pane to quit? + * + * Fails CLOSED on `unknown`, unlike most of this app's probes: everywhere else a probe that cannot + * answer degrades to the bare, pre-feature behaviour, but here the pre-feature behaviour is to type + * `/exit` into whatever is there. The conversation is only recoverable through the `--resume` this + * pair promises, so "we could not see the pane" must mean "leave it alone", not "quit it anyway". + */ +export function decideHibernateExit( + owner: PaneOwner | null | undefined, + agentId: string, + binaries?: readonly string[] | null +): ExitContextVerdict { + switch (isAgentPane(owner, agentId, binaries)) { + case 'agent': + return 'agent-owns-pane' + case 'not-agent': + return 'not-in-this-pane' + default: + return 'unreadable' + } +} + +/** The pane identity to remember, or null when the read gave us nothing to remember. Null is not a + * blank record — it is the absence of one, and the wake refuses on it. */ +export function captureWakeContext(owner: PaneOwner | null | undefined): WakeContext | null { + if (!owner || !owner.command || !(owner.panePid > 0)) return null + return { command: owner.command, panePid: owner.panePid, paneId: owner.paneId } +} + +/** + * `'resume'` is the only outcome that may be followed by a write. + * + * - `'agent-running'` — a CLI owns this pane again (the user relaunched one by hand, or a + * wrapper loop restarted it). A launch line typed into a live CLI is sent + * to it as a MESSAGE. Standing refusal. + * - `'no-proof'` — we exited this node ourselves but hold no record of the pane we exited + * into. Pre-fix records, and any record a failed post-exit read dropped. + * - `'context-changed'` — we hold a record and the pane no longer matches it. + * - `'unreadable'` — the pane could not be read. Transient; the caller may retry. + */ +export type WakeVerdict = 'resume' | 'agent-running' | 'no-proof' | 'context-changed' | 'unreadable' + +export interface WakeDecisionInput { + owner: PaneOwner | null | undefined + /** The proof recorded by the exit that produced this state, if any. */ + recorded: WakeContext | null | undefined + /** + * Did OUR exit put this pane on a shell (`agentStatus.hibernated`)? + * + * The two wake families need different questions, and this is what separates them. When we did + * the exit there is a specific pane we exited INTO, and matching it is the whole point. When we + * did not — a deep "pause & end session" deliberately RECYCLES the tmux session, and a `dropped` + * node's CLI died on its own — there is no prior pane to match, and requiring one would strand + * both behind a refusal they can never satisfy. Those keep exactly the shell recognition they + * have today, plus the agent-running refusal below, which they did not have. + */ + exitedByUs: boolean + agentId: string + binaries?: readonly string[] | null +} + +export function decideWakeResume(i: WakeDecisionInput): WakeVerdict { + if (!i.owner) return 'unreadable' + // Asked FIRST, and for both families: whatever else is true, a pane an agent is running in is + // never a pane to type a launch line into. + if (isAgentPane(i.owner, i.agentId, i.binaries) === 'agent') return 'agent-running' + const pane = i.owner.command + if (!pane) return 'unreadable' + if (!i.exitedByUs) return isShellCommand(pane) ? 'resume' : 'context-changed' + const rec = i.recorded + if (!rec) return 'no-proof' + if (i.owner.panePid !== rec.panePid) return 'context-changed' + // Compared only when BOTH sides carry one: a record taken before `paneId` existed, or a read the + // format expanded to nothing for, must not read as a mismatch — that is missing evidence, and + // `panePid` above is already holding the pane's identity. + if (rec.paneId && i.owner.paneId && rec.paneId !== i.owner.paneId) return 'context-changed' + if (!isShellCommand(pane) && pane !== rec.command) return 'context-changed' + return 'resume' +} + +/** Does this verdict mean "ask again in a moment"? Everything else is a standing refusal, and the + * wake's bounded retry must not spend its attempts on one. */ +export function wakeVerdictIsTransient(v: WakeVerdict): boolean { + return v === 'unreadable' +} + +/** What to tell the user about a wake that refused. One sentence, no jargon: the chip's tooltip is + * the only place any of this is ever visible. */ +export function wakeRefusalReason(v: WakeVerdict): string | null { + switch (v) { + case 'agent-running': + return 'Something is already running in this terminal, so the session was not resumed. Open it to see.' + case 'context-changed': + return 'This terminal is no longer the shell the session was paused in, so it was not resumed. Open it and resume by hand.' + case 'no-proof': + return 'This session was paused by an older version that did not record where, so it cannot be resumed safely. Open the terminal and resume by hand.' + default: + return null + } +} diff --git a/src/shared/composed-input.ts b/src/shared/composed-input.ts new file mode 100644 index 0000000000..bae64a617c --- /dev/null +++ b/src/shared/composed-input.ts @@ -0,0 +1,36 @@ +/** Explicit composer submission. Ordinary keys, terminal reports and wheels remain raw input. */ +export type ComposedInput = + | { kind: 'paste'; text: string; enter: boolean } + | { kind: 'control'; text: string; enter: false } + +export interface ComposedInputResult { + status: 'delivered' | 'refused' | 'uncertain' + message?: string +} + +export const COMPOSED_INPUT_MAX_BYTES = 262_144 +/** Give paste-aware composers time to settle before a separate Enter, matching the input bar. */ +export const COMPOSED_ENTER_DELAY_MS = 150 +export const COMPOSED_INPUT_UNCERTAIN = 'The text may already have reached the terminal. Inspect it before sending again.' +export const COMPOSED_INPUT_UNSUPPORTED = 'Composed Send is unavailable for this terminal. Update nodeterm on the computer or use its terminal.' + +/** Validate untrusted RPC input before resolving or touching a terminal. */ +export function parseComposedInput(value: unknown): ComposedInput | null { + if (!value || typeof value !== 'object') return null + const v = value as Record + if (typeof v.text !== 'string' || typeof v.enter !== 'boolean') return null + if (v.kind === 'control') { + // eslint-disable-next-line no-control-regex + return v.enter === false && /^[\x00-\x1f\x7f]$/.test(v.text) + ? { kind: 'control', text: v.text, enter: false } : null + } + return v.kind === 'paste' && !v.text.includes('\x00') && v.text.length <= COMPOSED_INPUT_MAX_BYTES && + new TextEncoder().encode(v.text).length <= COMPOSED_INPUT_MAX_BYTES + ? { kind: 'paste', text: v.text, enter: v.enter } : null +} + +/** xterm's paste line endings, with ESC/C1 removed so content cannot close the paste frame. */ +export function normalizeComposedPaste(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replace(/[\x1b\u009b]/g, '').replace(/\r?\n/g, '\r') +} diff --git a/src/shared/history-scroll.ts b/src/shared/history-scroll.ts new file mode 100644 index 0000000000..f5b2abdd53 --- /dev/null +++ b/src/shared/history-scroll.ts @@ -0,0 +1,44 @@ +/** Native retained-history scrolling. The mixed operation is input-capable and never replayed. */ +export interface HistoryScrollRow { + text: string + isWrapped: boolean + section: 'normal' | 'alternate' +} + +/** Bounded physical rows, including pre-attach history and the current alternate screen. */ +export interface HistoryScrollCapture { + cols: number + viewportRows: number + rows: HistoryScrollRow[] + olderTruncated: boolean +} + +export type NativeScrollResult = + | { status: 'history'; capture?: HistoryScrollCapture } + | { status: 'input' } + | { status: 'refused' | 'uncertain'; message: string } + +/** A pure emulator decision. Only the captured backend owner may perform these writes. */ +export type NativeScrollPlan = + | { status: 'history'; capture?: HistoryScrollCapture } + | { status: 'wheel'; data: string[] } + | { status: 'refused'; message: string } + +export interface HistoryScrollPage { + status: 'history' + viewId: string + offset: number + totalRows: number + cols: number + rows: HistoryScrollRow[] + olderTruncated: boolean + hasOlder: boolean + hasNewer: boolean +} + +export type HistoryScrollResult = HistoryScrollPage | Exclude +export const HISTORY_CAPTURE_MAX_BYTES = 1024 * 1024 +export const HISTORY_CAPTURE_MAX_ROWS = 8192 +export const HISTORY_PAGE_MAX_BYTES = 256 * 1024 +export const HISTORY_PAGE_MAX_ROWS = 200 +export const HISTORY_ROWS_PER_NOTCH = 3 diff --git a/src/shared/hook-answers.test.ts b/src/shared/hook-answers.test.ts new file mode 100644 index 0000000000..ed1c243900 --- /dev/null +++ b/src/shared/hook-answers.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { buildHookReply, hookQuestions, permissionSuggestions, HOOK_REPLY_MARKER } from './hook-answers' + +const permission = { + hook_event_name: 'PermissionRequest', tool_name: 'Bash', tool_input: { command: 'npm test' }, + permission_suggestions: [{ type: 'addRules', rules: [{ toolName: 'Bash', ruleContent: 'npm test' }], behavior: 'allow', destination: 'localSettings' }] +} +const question = { + hook_event_name: 'PreToolUse', tool_name: 'AskUserQuestion', tool_use_id: 'tool-1', + tool_input: { questions: [ + { question: 'Which sections?', header: 'Sections', options: [{ label: 'Intro', description: 'First' }, { label: '結論', description: 'Last' }], multiSelect: true }, + { question: '__proto__', header: 'Format', options: [{ label: 'Plain', description: '' }, { label: 'Detailed', description: '' }], multiSelect: false } + ], metadata: 'preserve original input' } +} +function output(value: string | null): any { + expect(value?.split('\n')[0]).toBe(HOOK_REPLY_MARKER) + return JSON.parse(value!.split('\n')[1]).hookSpecificOutput +} + +describe('layout-independent hook answers', () => { + it('remembers only the exact suggested rule and destination, with no keystrokes', () => { + expect(permissionSuggestions(permission)).toEqual([{ index: 0, label: 'Bash(npm test) — localSettings' }]) + const other = { ...permission.permission_suggestions[0], destination: 'session', rules: [{ toolName: 'Bash', ruleContent: 'npm build' }] } + expect(output(buildHookReply({ ...permission, permission_suggestions: [permission.permission_suggestions[0], other] }, + { kind: 'allow-always', suggestionIndex: 1 })).decision.updatedPermissions).toEqual([other]) + expect(output(buildHookReply(permission, { kind: 'allow-always', suggestionIndex: 0 }))).toEqual({ + hookEventName: 'PermissionRequest', decision: { behavior: 'allow', updatedPermissions: permission.permission_suggestions } + }) + }) + it('rejects mode changes, denial rules, whole-tool grants, wildcards and another tool', () => { + for (const update of [ + { ...permission.permission_suggestions[0], type: 'setMode', mode: 'bypassPermissions' }, + { ...permission.permission_suggestions[0], behavior: 'deny' }, + { ...permission.permission_suggestions[0], rules: [{ toolName: 'Bash' }] }, + { ...permission.permission_suggestions[0], rules: [{ toolName: 'Bash', ruleContent: '*' }] }, + { ...permission.permission_suggestions[0], rules: [{ toolName: 'Write', ruleContent: '/tmp/a' }] }, + { ...permission.permission_suggestions[0], destination: 'managedSettings' } + ]) { + const request = { ...permission, permission_suggestions: [update] } + expect(permissionSuggestions(request)).toEqual([]) + expect(buildHookReply(request, { kind: 'allow-always', suggestionIndex: 0 })).toBeNull() + } + }) + it('does not infer a remember rule when suggestions are absent or use the wrong event', () => { + expect(permissionSuggestions({ ...permission, permission_suggestions: undefined })).toEqual([]) + expect(buildHookReply(question, { kind: 'allow-always', suggestionIndex: 0 })).toBeNull() + for (const suggestionIndex of [-1, 1, 0.1, NaN]) expect(buildHookReply(permission, { kind: 'allow-always', suggestionIndex })).toBeNull() + }) + it('validates full question schema and returns all original inputs with Unicode multi-select answers', () => { + expect(hookQuestions(question)).toEqual(question.tool_input.questions) + const reply = output(buildHookReply(question, { kind: 'question', selections: [[1, 0], [1]] })) + expect(reply.hookEventName).toBe('PreToolUse') + expect(reply.permissionDecision).toBe('allow') + expect(reply.updatedInput.questions).toEqual(question.tool_input.questions) + expect(reply.updatedInput.metadata).toBe('preserve original input') + expect(reply.updatedInput.answers).toEqual({ 'Which sections?': 'Intro, 結論', ['__proto__']: 'Detailed' }) + }) + it('requires every question and rejects duplicates, invalid indices and multiple single-select answers', () => { + for (const selections of [[], [[0]], [[0], [0], [0]], [[0], []], [[0, 0], [0]], [[-1], [0]], [[2], [0]], [[0.5], [0]], [[0], [0, 1]]]) { + expect(buildHookReply(question, { kind: 'question', selections })).toBeNull() + } + }) + it('rejects duplicate question text or option labels rather than overwrite an answer', () => { + const q = question.tool_input.questions[0] + for (const questions of [[q, q], [{ ...q, options: [q.options[0], q.options[0]] }]]) { + expect(hookQuestions({ ...question, tool_input: { questions } })).toBeNull() + } + }) + it('does not answer a legacy PermissionRequest question as a PreToolUse hook', () => { + const request = { ...question, hook_event_name: 'PermissionRequest' } + expect(hookQuestions(request)).toBeNull() + expect(buildHookReply(request, { kind: 'question', selections: [[0], [0]] })).toBeNull() + }) + it('bounds final UTF-8 reply bytes and refuses child questions without a parent card', () => { + expect(buildHookReply({ ...question, agent_id: 'child' }, { kind: 'question', selections: [[0], [0]] })).toBeNull() + expect(buildHookReply({ ...question, tool_input: { ...question.tool_input, metadata: '界'.repeat(50000) } }, + { kind: 'question', selections: [[0], [0]] })).toBeNull() + }) + it('bounds schema and suggestions without publishing a truncated answerable picker', () => { + const q = question.tool_input.questions[0] + for (const questions of [Array(5).fill(q), [{ ...q, question: 'x'.repeat(4097) }], [{ ...q, options: Array(5).fill(q.options[0]) }]]) { + expect(hookQuestions({ ...question, tool_input: { questions } })).toBeNull() + } + expect(permissionSuggestions({ ...permission, permission_suggestions: Array(33).fill(permission.permission_suggestions[0]) })).toEqual([]) + }) +}) diff --git a/src/shared/hook-answers.ts b/src/shared/hook-answers.ts new file mode 100644 index 0000000000..180d30569f --- /dev/null +++ b/src/shared/hook-answers.ts @@ -0,0 +1,122 @@ +// Claude's documented hook decision contract, independent of prompt layout or keystrokes. +// https://code.claude.com/docs/en/hooks#tools-that-require-user-interaction +// https://code.claude.com/docs/en/hooks#permission-update-entries + +export const HOOK_REPLY_MARKER = 'nodeterm-hook-reply-v2' +export const HOOK_REQUEST_MAX_BYTES = 128 * 1024 + +export interface PermissionSuggestion { + index: number + label: string +} + +export interface HookQuestion { + question: string + header: string + options: { label: string; description: string }[] + multiSelect: boolean +} + +export type HookAnswer = + | { kind: 'allow-always'; suggestionIndex: number } + | { kind: 'question'; selections: number[][] } + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record : null +} + +function text(value: unknown, max: number): value is string { + return typeof value === 'string' && value.trim().length > 0 && value.length <= max +} + +/** Only concrete allow rules supplied by this exact request, never a permission-mode change. */ +function permissionUpdate(request: unknown, index: number): Record | null { + const req = record(request) + if (req?.hook_event_name !== 'PermissionRequest' || !text(req.tool_name, 128)) return null + const suggestions = req.permission_suggestions + if (!Array.isArray(suggestions) || suggestions.length > 32 || !Number.isInteger(index) || index < 0) return null + const update = record(suggestions[index]) + if (!update || update.type !== 'addRules' || update.behavior !== 'allow' || + !['session', 'localSettings', 'projectSettings', 'userSettings'].includes(String(update.destination))) return null + const rules = update.rules + if (!Array.isArray(rules) || rules.length === 0 || rules.length > 8) return null + const checked: { toolName: string; ruleContent: string }[] = [] + for (const raw of rules) { + const rule = record(raw) + // A missing ruleContent grants the whole tool. This feature remembers only an explicit scope. + if (!rule || rule.toolName !== req.tool_name || !text(rule.ruleContent, 4096) || rule.ruleContent === '*') return null + checked.push({ toolName: req.tool_name, ruleContent: rule.ruleContent }) + } + return { type: 'addRules', rules: checked, behavior: 'allow', destination: update.destination } +} + +export function permissionSuggestions(request: unknown): PermissionSuggestion[] { + const req = record(request) + const suggestions = req?.permission_suggestions + if (!Array.isArray(suggestions) || suggestions.length > 32) return [] + return suggestions.flatMap((_, index) => { + const update = permissionUpdate(req, index) + if (!update) return [] + const rules = update.rules as { toolName: string; ruleContent: string }[] + return [{ index, label: `${rules.map(r => `${r.toolName}(${r.ruleContent})`).join(', ')} — ${update.destination}` }] + }) +} + +/** Full, bounded schema. Never answer only the first of several questions or clipped labels. */ +export function hookQuestions(request: unknown): HookQuestion[] | null { + const req = record(request) + if (req?.hook_event_name !== 'PreToolUse' || req.tool_name !== 'AskUserQuestion' || + (typeof req.agent_id === 'string' && req.agent_id.length > 0)) return null + const input = record(req.tool_input) + const questions = input?.questions + if (!Array.isArray(questions) || questions.length === 0 || questions.length > 4) return null + const result: HookQuestion[] = [] + const seen = new Set() + for (const raw of questions) { + const q = record(raw) + if (!q || !text(q.question, 4096) || seen.has(q.question) || !text(q.header, 80) || + (q.multiSelect !== undefined && typeof q.multiSelect !== 'boolean')) return null + seen.add(q.question) + if (!Array.isArray(q.options) || q.options.length < 2 || q.options.length > 4) return null + const options: HookQuestion['options'] = [] + const labels = new Set() + for (const rawOption of q.options) { + const option = record(rawOption) + if (!option || !text(option.label, 256) || labels.has(option.label) || + typeof option.description !== 'string' || option.description.length > 4096) return null + labels.add(option.label) + options.push({ label: option.label, description: option.description }) + } + result.push({ question: q.question, header: q.header, options, multiSelect: q.multiSelect === true }) + } + return result +} + +/** Build only from the original live request. Callers supply indexes, never hook JSON or rules. */ +export function buildHookReply(request: unknown, answer: HookAnswer): string | null { + let hookSpecificOutput: Record + if (answer?.kind === 'allow-always') { + const update = permissionUpdate(request, answer.suggestionIndex) + if (!update) return null + hookSpecificOutput = { hookEventName: 'PermissionRequest', decision: { behavior: 'allow', updatedPermissions: [update] } } + } else if (answer?.kind === 'question') { + const questions = hookQuestions(request) + if (!questions || !Array.isArray(answer.selections) || answer.selections.length !== questions.length) return null + const answers: Record = Object.create(null) + for (let i = 0; i < questions.length; i++) { + const q = questions[i] + const selected = answer.selections[i] + if (!Array.isArray(selected) || selected.length === 0 || selected.length > q.options.length || + (!q.multiSelect && selected.length !== 1) || new Set(selected).size !== selected.length || + selected.some(n => !Number.isInteger(n) || n < 0 || n >= q.options.length)) return null + // Claude documents the comma-space representation for multi-select labels. + answers[q.question] = [...selected].sort((a, b) => a - b).map(n => q.options[n].label).join(', ') + } + const req = request as Record + hookSpecificOutput = { hookEventName: 'PreToolUse', permissionDecision: 'allow', + updatedInput: { ...record(req.tool_input), answers } } + } else return null + const output = JSON.stringify({ hookSpecificOutput }) + return new TextEncoder().encode(output).length <= HOOK_REQUEST_MAX_BYTES ? `${HOOK_REPLY_MARKER}\n${output}` : null +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 1bc9e6a1c6..368541d393 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -28,6 +28,7 @@ export const IPC = { ptySessionAge: 'pty:session-age', ptyReadScrollback: 'pty:read-scrollback', ptySendText: 'pty:send-text', + ptyWakeSleeping: 'pty:wake-sleeping', ptySendChatPrompt: 'pty:send-chat-prompt', ptyTmuxStatus: 'pty:tmux-status', /** The foreground command of a node's tmux pane (`#{pane_current_command}`) — how the in-place @@ -753,6 +754,7 @@ export const IPC = { pairingProbeSsh: 'pairing:probe-ssh', pairingOpenRemoteLoginSettings: 'pairing:open-remote-login-settings', pairingListDevices: 'pairing:listDevices', + pairingListNetworks: 'pairing:listNetworks', // Push webhook management (core/push-webhook.ts). Under `pairing:` so HOST_ONLY_CHANNEL_PREFIXES // keeps them off the relay: minting a token that pushes to the host's phones is the host's call. pairingWebhookStatus: 'pairing:webhook-status', diff --git a/src/shared/managed-terminal.ts b/src/shared/managed-terminal.ts new file mode 100644 index 0000000000..ace020eb01 --- /dev/null +++ b/src/shared/managed-terminal.ts @@ -0,0 +1,21 @@ +/** An additive, versioned receipt for a terminal actually created by its owning host. + * It grants attach-only viewing, never permission to restore privileged pane ownership. */ +export interface ManagedPaneReceipt { + version: 1 + creationId: string + nodeId: string + projectId: string + socket: 'node-terminal' + session: string + paneId: string + panePid: number + paneBirth: string + sessionCreated: string +} + +export interface ManagedTerminalReceipt extends ManagedPaneReceipt { + hostInstance: string +} + +export const MANAGED_CREATION_ENV = 'NODETERM_MANAGED_CREATION_ID' +export const MANAGED_CREATION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ diff --git a/src/shared/pairing-network.test.ts b/src/shared/pairing-network.test.ts new file mode 100644 index 0000000000..81ffcd1f5b --- /dev/null +++ b/src/shared/pairing-network.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { pairingNetworkChoices, pairingNetworkIPv4, type PairingInterfaces } from './pairing-network' + +const interfaces: PairingInterfaces = { + lo: [{ address: '127.0.0.1', family: 'IPv4', internal: true }], + docker0: [{ address: '172.17.0.1', family: 'IPv4', internal: false }], + wg0: [{ address: '10.7.0.2', family: 'IPv4', internal: false }], + wlan0: [{ address: '192.168.1.42', family: 'IPv4', internal: false }], +} + +describe('phone pairing network choice', () => { + it('does not let an earlier container or VPN adapter hide the physical LAN', () => { + expect(pairingNetworkIPv4(interfaces)).toBe('192.168.1.42') + }) + it('allows an explicit VPN choice, ahead of a default-route hint', () => { + expect(pairingNetworkIPv4(interfaces, 'wg0', '192.168.1.42')).toBe('10.7.0.2') + }) + it('keeps the selected adapter after its DHCP address changes', () => { + const moved = { ...interfaces, wlan0: [{ address: '192.168.1.77', family: 4, internal: false }] } + expect(pairingNetworkIPv4(moved, 'wlan0')).toBe('192.168.1.77') + }) + it('does not silently switch after a selected adapter disappears', () => { + expect(pairingNetworkIPv4({ docker0: interfaces.docker0 }, 'wlan0')).toBeNull() + }) + it('keeps a virtual-only machine usable and lists its current choices', () => { + const only = { wg0: interfaces.wg0, absent: undefined } + expect(pairingNetworkIPv4(only)).toBe('10.7.0.2') + expect(pairingNetworkChoices(only)).toEqual([{ interfaceName: 'wg0', address: '10.7.0.2' }]) + }) + it('admits a route hint only when it names a current address, including a secondary address', () => { + const multi = { ...interfaces, wlan0: [...interfaces.wlan0!, { address: '192.168.1.99', family: 4, internal: false }] } + expect(pairingNetworkIPv4(multi, '', '192.168.1.99')).toBe('192.168.1.99') + expect(pairingNetworkIPv4(multi, '', '203.0.113.8')).toBe('192.168.1.42') + }) + it.each(['br-1234', 'veth1234', 'virbr0', 'utun3', 'tun0', 'tap0', 'Tailscale', 'vEthernet (WSL)', 'ppp0'])('prefers LAN ahead of automatic %s', (name) => { + expect(pairingNetworkIPv4({ [name]: interfaces.wg0, en0: interfaces.wlan0 })).toBe('192.168.1.42') + }) + it.each(['0.0.0.0', '127.0.0.2', '169.254.1.8', '224.0.0.1', '255.255.255.255', '10.0.0.999', '010.0.0.1', 'host.example', '10.0.0.1\n'])('never advertises invalid or excluded address %s', (address) => { + const bad = { selected: [{ address, family: 'IPv4', internal: false }], ...interfaces } + expect(pairingNetworkIPv4(bad, 'selected')).toBeNull() + expect(pairingNetworkChoices(bad).some((choice) => choice.interfaceName === 'selected')).toBe(false) + }) + it('requires IPv4 and non-internal metadata and does not guess malformed settings', () => { + const bad = { selected: [{ address: '10.0.0.8', family: 'IPv6', internal: false }] } + expect(pairingNetworkIPv4(bad, 'selected')).toBeNull() + expect(pairingNetworkIPv4(interfaces, 42)).toBeNull() + expect(pairingNetworkIPv4(interfaces, 'missing')).toBeNull() + }) +}) diff --git a/src/shared/pairing-network.ts b/src/shared/pairing-network.ts new file mode 100644 index 0000000000..2cf2aaf1e5 --- /dev/null +++ b/src/shared/pairing-network.ts @@ -0,0 +1,62 @@ +/** Addresses reported by the computer's own OS, never by a phone or an SSH viewer. */ +export interface PairingNetworkAddress { + address: string + family: string | number + internal: boolean +} + +export type PairingInterfaces = Record + +export interface PairingNetworkChoice { + interfaceName: string + address: string +} + +// Prefer a physical LAN adapter for automatic pairing. A VPN can be selected explicitly; +// virtual-only machines keep a fallback rather than pretending they have no network. +const VIRTUAL_INTERFACE = /vEthernet|WSL|Hyper-V|VirtualBox|VMware|Loopback|Tailscale|ZeroTier|Npcap|TAP-|Docker|^(?:br-|veth|virbr|tun\d|tap\d|utun\d|wg\d|zt\w|ppp\d)/i + +function unicastIPv4(address: unknown): address is string { + if (typeof address !== 'string' || !/^(?:0|[1-9]\d{0,2})(?:\.(?:0|[1-9]\d{0,2})){3}$/.test(address)) return false + const octets = address.split('.').map(Number) + return octets.every((n) => n <= 255) && octets[0] > 0 && octets[0] < 224 && + octets[0] !== 127 && !(octets[0] === 169 && octets[1] === 254) +} + +/** One current IPv4 per adapter. This lists addresses, not proof that a phone can reach them. */ +export function pairingNetworkChoices(interfaces: PairingInterfaces): PairingNetworkChoice[] { + const choices: PairingNetworkChoice[] = [] + for (const [interfaceName, addresses] of Object.entries(interfaces)) { + if (!interfaceName || !Array.isArray(addresses)) continue + const current = addresses.find((entry) => entry && !entry.internal && + (entry.family === 'IPv4' || entry.family === 4) && unicastIPv4(entry.address)) + if (current) choices.push({ interfaceName, address: current.address }) + } + return choices +} + +/** + * An explicit adapter stays selected across DHCP changes. If it disappears, there is no + * address to advertise; silently switching to a different VPN/LAN would undo the choice. + * Automatic prefers a physical adapter, falling back to the first valid virtual adapter. + * Windows may supply its existing OS default-route hint; it must belong to a current adapter. + */ +export function pairingNetworkIPv4( + interfaces: PairingInterfaces, + preferredInterface: unknown = '', + routeAddress: string | null = null +): string | null { + const choices = pairingNetworkChoices(interfaces) + if (preferredInterface !== '' && preferredInterface !== null && preferredInterface !== undefined) { + if (typeof preferredInterface !== 'string') return null + return choices.find((choice) => choice.interfaceName === preferredInterface)?.address ?? null + } + if (routeAddress) { + for (const addresses of Object.values(interfaces)) { + if (addresses?.some((entry) => entry && !entry.internal && + (entry.family === 'IPv4' || entry.family === 4) && unicastIPv4(entry.address) && + entry.address === routeAddress)) return routeAddress + } + } + return choices.find((choice) => !VIRTUAL_INTERFACE.test(choice.interfaceName))?.address ?? choices[0]?.address ?? null +} diff --git a/src/shared/relay-pairing-proof.test.ts b/src/shared/relay-pairing-proof.test.ts new file mode 100644 index 0000000000..ba0b363723 --- /dev/null +++ b/src/shared/relay-pairing-proof.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { relayPairingChallengeText, RELAY_PAIRING_KEY, RELAY_PAIRING_SIGNATURE, type RelayPairingChallenge } from './relay-pairing-proof' + +const challenge: RelayPairingChallenge = { version: 1, challengeId: '11111111-1111-4111-8111-111111111111', + pairingId: '22222222-2222-4222-8222-222222222222', hostPublicKeyB64: Buffer.alloc(32, 1).toString('base64'), + peerPublicKeyB64: Buffer.alloc(32, 2).toString('base64'), sshPublicKeyB64: Buffer.alloc(32, 3).toString('base64'), + nonceB64: Buffer.alloc(32, 4).toString('base64'), expiresAtMs: 1_800_000_000_000 } + +describe('relay pairing signed bytes', () => { + it('fixes every field position, domain, decimal expiry and final newline independently', () => { + expect(relayPairingChallengeText(challenge)).toBe('nodeterm-relay-pairing-v1\n1\n11111111-1111-4111-8111-111111111111\n' + + '22222222-2222-4222-8222-222222222222\nAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=\n' + + 'AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=\nAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=\n' + + 'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ=\n1800000000000\n') + }) + it.each(['challengeId', 'pairingId', 'hostPublicKeyB64', 'peerPublicKeyB64', 'sshPublicKeyB64', 'nonceB64'] as const)( + 'rejects malformed %s before signing', field => expect(() => relayPairingChallengeText({ ...challenge, [field]: 'bad\nfield' })).toThrow()) + it.each([0, -1, 1.5, Infinity, NaN, Number.MAX_SAFE_INTEGER + 1])('rejects expiry %s', expiresAtMs => + expect(() => relayPairingChallengeText({ ...challenge, expiresAtMs })).toThrow()) + it('rejects aliases with noncanonical padding bits and wrong byte lengths', () => { + expect(RELAY_PAIRING_KEY.test(Buffer.alloc(32).toString('base64'))).toBe(true) + expect(RELAY_PAIRING_KEY.test('A'.repeat(42) + 'B=')).toBe(false) + expect(RELAY_PAIRING_KEY.test(Buffer.alloc(31).toString('base64'))).toBe(false) + expect(RELAY_PAIRING_SIGNATURE.test(Buffer.alloc(64).toString('base64'))).toBe(true) + expect(RELAY_PAIRING_SIGNATURE.test('A'.repeat(85) + 'B==')).toBe(false) + expect(RELAY_PAIRING_SIGNATURE.test(Buffer.alloc(63).toString('base64'))).toBe(false) + }) +}) diff --git a/src/shared/relay-pairing-proof.ts b/src/shared/relay-pairing-proof.ts new file mode 100644 index 0000000000..90e614e6ef --- /dev/null +++ b/src/shared/relay-pairing-proof.ts @@ -0,0 +1,32 @@ +/** An approved relay binds an old pairing only by proving its retained SSH identity. */ +export const RELAY_PAIRING_CHALLENGE_METHOD = 'pairing.relayKeyChallengeV1' +export const RELAY_PAIRING_PROOF_METHOD = 'pairing.relayKeyProofV1' +export const RELAY_PAIRING_PROOF_DOMAIN = 'nodeterm-relay-pairing-v1' +export const RELAY_PAIRING_PROOF_TTL_MS = 60_000 +export const RELAY_PAIRING_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +/** Standard padded base64, including canonical final padding bits. */ +export const RELAY_PAIRING_KEY = /^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=$/ +export const RELAY_PAIRING_SIGNATURE = /^[A-Za-z0-9+/]{85}[AQgw]==$/ + +export interface RelayPairingChallenge { + version: 1 + challengeId: string + pairingId: string + hostPublicKeyB64: string + peerPublicKeyB64: string + sshPublicKeyB64: string + nonceB64: string + expiresAtMs: number +} +export type RelayPairingStatus = 'associated' | 'unprovable' | 'gone' | 'conflict' | 'expired' +export type RelayPairingChallengeResult = { status: RelayPairingStatus } | + { status: 'challenge'; challenge: RelayPairingChallenge } +export interface RelayPairingProofResult { status: RelayPairingStatus } + +export function relayPairingChallengeText(c: RelayPairingChallenge): string { + if (c.version !== 1 || !RELAY_PAIRING_UUID.test(c.challengeId) || !RELAY_PAIRING_UUID.test(c.pairingId) || + ![c.hostPublicKeyB64, c.peerPublicKeyB64, c.sshPublicKeyB64, c.nonceB64].every(k => RELAY_PAIRING_KEY.test(k)) || + !Number.isSafeInteger(c.expiresAtMs) || c.expiresAtMs <= 0) throw new Error('Invalid relay pairing challenge.') + return [RELAY_PAIRING_PROOF_DOMAIN, '1', c.challengeId, c.pairingId, c.hostPublicKeyB64, + c.peerPublicKeyB64, c.sshPublicKeyB64, c.nonceB64, String(c.expiresAtMs), ''].join('\n') +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 679d2e87a5..bf11d87e1e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -28,6 +28,7 @@ import type { KanbanPullAutoMove, KanbanPullLinks } from './kanban-pull-links' import type { BoardDispatch } from './board-dispatch' import type { CodexAccount } from './codex-account' import type { NotchAlign } from './notch-hud' +import type { PairingNetworkChoice } from './pairing-network' import type { ProjectIcon, ProjectIconPickResult } from './project-icon' import type { AlertSoundKind, AlertSoundSaveResult, CustomAlertSounds } from './alert-sound' import type { CanvasLayout, LayoutViewports } from './canvas-layout' @@ -1317,6 +1318,8 @@ export interface PtyApi { * false if unavailable; `pasted-not-submitted` means input was accepted but Enter was not * confirmed written. Surface it without automatically resending. True is not an app receipt. */ sendText(persistKey: string, text: string, opts?: { enter?: boolean }): Promise + /** Resume only an existing generation whose freshly read pane still satisfies its saved wake proof. */ + wakeSleeping(request: import('./agents/sleeping-wake').SleepingWakeRequest): Promise /** Submit a prompt from the ⌘M chat view. For an agent whose screen can be read * (`readsScreenDialogs`), refused BEFORE anything is written when the agent's own dialog owns the * keyboard (`ChatPromptBlocked`) — such dialogs fire no hook. Otherwise the `sendText` contract. */ @@ -1413,8 +1416,8 @@ export interface WorkspaceApi { onCorruptRecovered(cb: (backupFile: string) => void): () => void /** Fired when a project file changed on disk outside the app (git pull, sync, teammate). */ onExternalChange(cb: (project: Project) => void): () => void - /** Fired when THIS core wrote the project itself (Server Edition headless canvas control: an agent - * opened, renamed, moved, closed…). Not an outside edit — the renderer merges it, never asks. */ + /** Fired when THIS core wrote the project itself (phone Board edits; Server Edition headless + * canvas control). Not an outside edit — the renderer merges it, never asks. */ onServerChange(cb: (project: Project) => void): () => void } @@ -2181,6 +2184,9 @@ export interface Settings { * (end-to-end encrypted). Default on — the host only admits SAS-approved, pinned devices, so * an un-paired install just keeps an idle listener. Toggle in Settings → Phone. */ phoneAccessEnabled: boolean + /** Adapter name for pairing and authenticated LAN reports. Empty = automatic; an unavailable + * explicit adapter advertises no address. The current OS address follows DHCP changes. */ + phonePairingInterface: string /** Send APNs push notifications to relay-paired phones when an agent needs approval, asks a * question, or finishes a turn (spec: apns-push). Default on — it only fires for users who * have paired a phone. Toggle in Settings → Notifications. */ @@ -2419,6 +2425,7 @@ export const DEFAULT_SETTINGS: Settings = { telemetryEnabled: true, debugLogPanel: false, phoneAccessEnabled: true, + phonePairingInterface: '', mobilePushEnabled: true, mobilePushNeedsYou: true, mobilePushDone: true, @@ -4081,6 +4088,23 @@ export interface PairedDevice { */ export type DeviceRevokeServerOutcome = 'ok' | 'failed' | 'skipped' +/** + * The relay leg of a device revoke (audit A07-revoke): the phone's relay key unpinned on the standing + * host and the relay sessions it had open closed (remote/revocation.ts's persisted/killed). + * 'ok' = both done; 'unpin-failed' = the pin could not be written away, so it may survive and the + * phone would be let in again without a dialog: the device is kept LISTED and `local` is false, so + * Revoke can be retried; 'cut-unconfirmed' = unpinned, but closing a session it had open failed. + * 'unconfirmed' = this pairing has no recorded relay identity, or no revoker could act on it: + * removing its local entry/key does NOT prove remote access was taken away. 'retained' = another + * listed pairing still authorizes this same relay identity, so its pin and sessions were kept. + */ +export type DeviceRevokeRelayOutcome = + | 'ok' + | 'unpin-failed' + | 'cut-unconfirmed' + | 'unconfirmed' + | 'retained' + /** * Both legs of a device revoke, reported independently so a half-finished removal can never render * as a clean one (the same discipline as remote/revocation.ts's persisted/killed). @@ -4091,10 +4115,18 @@ export interface DeviceRevokeResult { local: boolean /** Whether the phone's Pro entitlement was taken back on the relay backend. */ server: DeviceRevokeServerOutcome + /** + * Reported after a listed device was removed locally, including an unconfirmed/retained leg. + * Absent when no device was listed, local removal failed before the relay leg, or an older main + * process did not report it. Absence must never be treated as confirmed relay revocation. + */ + relay?: DeviceRevokeRelayOutcome } /** Phone-pairing (nodeterm iOS "scan a QR" flow) bridge. */ export interface PairingApi { + /** Current usable IPv4 adapters on this computer, not a phone reachability test. Desktop only. */ + listNetworks(): Promise /** Start the one-shot LAN listener; resolves with the QR payload + an SSH-reachable hint. */ start(): Promise<{ payload: string @@ -4129,8 +4161,9 @@ export interface PairingApi { /** List paired devices from ~/.nodeterm/agent.json (never includes the token). */ listDevices(): Promise /** - * Revoke a device: remove its registry entry, delete its authorized_keys line, and take its Pro - * entitlement back on the relay backend. Never rejects for a leg that failed — read the result. + * Revoke a device: remove its registry entry, delete its authorized_keys line, unpin its relay key + * and close the relay sessions it has open, and take its Pro entitlement back on the relay + * backend. Never rejects for a leg that failed — read the result. */ revokeDevice(id: string): Promise /** Push webhook (shared/push-webhook.ts): what token is live for this machine — never its value. */ @@ -4377,10 +4410,10 @@ export interface NodeTerminalApi { seedAgentIdentity(entries: IdentitySeedEntry[]): void /** Fires when the core asks this renderer to WAKE a hibernated node NOW (a phone viewer just * attached to its session over the relay). A nudge with `wakeHibernatedNode`'s exact contract: - * re-read the flag, no-op when not hibernated or not mounted. Returns unsubscribe. + * re-read the flag, no-op when not hibernated; resolves saved offscreen nodes without switching projects. Returns unsubscribe. * Desktop-only signal (the relay host lives in the desktop main process); the ws-bridge * subscribes to nothing and returns a no-op unsubscribe. */ - onAgentWake(listener: (nodeId: string) => void): () => void + onAgentWake(listener: (nodeId: string, automatic?: boolean) => void): () => void /** Fires with the CURRENT set of node ids that have a live relay (phone) viewer attached — the * full set each change, never a delta. Feeds `isNodeWatched` so Eco cannot hibernate a session * someone is watching from a phone. Desktop-only signal, like `onAgentWake`. */ diff --git a/test/acceptance/phone-board-updates.test.ts b/test/acceptance/phone-board-updates.test.ts new file mode 100644 index 0000000000..2776f47a23 --- /dev/null +++ b/test/acceptance/phone-board-updates.test.ts @@ -0,0 +1,170 @@ +// @vitest-environment jsdom +// Real WorkspaceStore writes + renderer store + mounted production Board. The small event +// harness follows Canvas's two existing consumers: outside edits may conflict while dirty; +// this core's server-change is adopted without reloading the canvas. No PTY/device is started. +import { createElement, act, useEffect } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { promises as fs } from 'fs' +import os from 'os' +import path from 'path' +import { initPlatform, resetPlatformForTests } from '../../src/core/platform' +import { fakePlatform } from '../../src/core/platform-fake' +import { WorkspaceStore, type RemoteWorkspaceIO } from '../../src/core/workspace-store' +import { IPC } from '../../src/shared/ipc' +import type { CanvasNodeState, NodeTerminalApi, Project, ProjectKanban } from '../../src/shared/types' +import { useProjects } from '../../src/renderer/state/projects' +import { KanbanView } from '../../src/renderer/components/kanban/KanbanView' +import { defaultKanban } from '../../src/renderer/lib/kanban' +import { decideExternalChange } from '../../src/renderer/lib/externalChange' +import { buildRealApi } from '../../src/renderer/bridge/ws-bridge' +import { planServerChange } from '../../src/renderer/lib/serverChange' + +const desktop = vi.hoisted(() => { + const listeners = new Map void>>() + return { + exposed: {} as Record, listeners, + on: (channel: string, fn: (...args: unknown[]) => void) => { + const set = listeners.get(channel) ?? new Set(); set.add(fn); listeners.set(channel, set) + }, + off: (channel: string, fn: (...args: unknown[]) => void) => listeners.get(channel)?.delete(fn), + emit: (channel: string, ...args: unknown[]) => listeners.get(channel)?.forEach(fn => fn({ sender: 'fixture' }, ...args)) + } +}) +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld: (key: string, api: unknown) => { desktop.exposed[key] = api } }, + ipcRenderer: { on: desktop.on, removeListener: desktop.off, invoke: vi.fn(), send: vi.fn() }, + webUtils: { getPathForFile: vi.fn() } +})) +import '../../src/preload/index' + +vi.mock('../../src/renderer/session/session', () => ({ useSession: () => ({ api: {} }) })) +// These modal-only panels are closed throughout this regression; avoid loading terminal/browser +// runtime dependencies. The Board, columns, cards and label chips are all real components. +vi.mock('../../src/renderer/components/kanban/ModalTerminal', () => ({ ModalTerminal: () => null })) +vi.mock('../../src/renderer/nodes/BrowserSurface', () => ({ BrowserSurface: () => null })) + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const node: CanvasNodeState = { + id: 'term-phone-1', kind: 'terminal', title: 'QA relay shell', color: '#10a37f', group: null, + position: { x: 10, y: 20 }, size: { width: 800, height: 600 } +} +const board = (): ProjectKanban => ({ + columns: [{ id: 'kcol-progress', title: 'In Progress', color: '#10a37f' }], assignments: [], + labels: [{ id: 'klbl-hike', name: 'Hike QA', color: 'blue' }] +}) +const noop = (): void => {} +let userData: string, cwd: string, host: HTMLDivElement, root: Root +let mounts: number, conflicts: number, reloads: number +let unsubscribers: Array<() => void> + +beforeEach(async () => { + userData = await fs.mkdtemp(path.join(os.tmpdir(), 'nt-phone-board-user-')) + cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'nt-phone-board-project-')) + mounts = 0; conflicts = 0; reloads = 0; unsubscribers = []; desktop.listeners.clear() + host = document.createElement('div'); document.body.append(host); root = createRoot(host) +}) +afterEach(async () => { + unsubscribers.forEach(off => off()) + act(() => root.unmount()) + useProjects.setState({ projects: [], activeProjectId: '' }) + host.remove(); resetPlatformForTests() + await fs.rm(userData, { recursive: true, force: true }) + await fs.rm(cwd, { recursive: true, force: true }) +}) + +async function fixture(surface: 'desktop' | 'server', kind: 'local' | 'ssh', initialBoard: ProjectKanban | null = board()) { + const project: Project = { + id: 'phone-board-project', name: 'QA project', color: '#10a37f', viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [node], ...(initialBoard ? { kanban: initialBoard } : {}), + ...(kind === 'local' ? { cwd } : { ssh: { server: { host: 'owned.invalid', user: 'qa' }, remoteCwd: '/owned/qa' } as Project['ssh'] }) + } + let remoteContent: string | null = null + const remoteIO: RemoteWorkspaceIO = { + read: async () => remoteContent === null ? { status: 'absent' } : { status: 'ok', content: remoteContent }, + write: async (_id, _ssh, content) => { remoteContent = content; return true } + } + const platform = fakePlatform({ userDataDir: userData }); initPlatform(platform) + const store = new WorkspaceStore(kind === 'ssh' ? remoteIO : undefined) + await store.save({ version: 2, activeProjectId: project.id, projects: [project] }) + useProjects.getState().hydrate(await store.load()) + // An unsaved local drag is precisely why an outside-file kanban edit would conflict. + const liveNodes = [{ ...node, position: { x: 900, y: 700 } }] + const serverListeners = new Map void>>() + const serverClient = { + subscribe: (channel: string, fn: (...args: unknown[]) => void) => { + const set = serverListeners.get(channel) ?? new Set(); set.add(fn); serverListeners.set(channel, set) + return () => { set.delete(fn) } + } + } + // The production Desktop preload or Server WebSocket adapter receives the real core broadcast. + const workspace = surface === 'desktop' + ? (desktop.exposed.nodeTerminal as NodeTerminalApi).workspace + : buildRealApi(serverClient as never).workspace + unsubscribers.push(workspace.onExternalChange(incoming => { + const decision = decideExternalChange({ dirty: true, base: useProjects.getState().getProject(project.id), incoming, liveNodeIds: liveNodes.map(n => n.id) }) + if (decision.kind === 'conflict') conflicts++ + if (decision.kind === 'reload') reloads++ + if (decision.kind === 'merge' || decision.kind === 'reload') useProjects.getState().replaceProject(incoming) + })) + unsubscribers.push(workspace.onServerChange(incoming => { + const plan = planServerChange({ base: useProjects.getState().getProject(project.id), incoming, + liveNodeIds: liveNodes.map(n => n.id), liveRopes: [], liveBridges: [] }) + liveNodes.push(...plan.added) + useProjects.getState().replaceProject(incoming) + })) + const broadcast = platform.broadcast + platform.broadcast = (channel, ...args) => { + broadcast(channel, ...args) + if (surface === 'desktop') desktop.emit(channel, ...args) + else serverListeners.get(channel)?.forEach(fn => fn(...args)) + } + const seed = defaultKanban() + function MountedBoard() { + const p = useProjects(s => s.projects.find(p => p.id === project.id))! + useEffect(() => { mounts++ }, []) + return createElement(KanbanView, { + board: p.kanban ?? seed, + sessions: liveNodes.map(n => ({ id: n.id, title: n.title, color: n.color, kind: 'terminal' as const, spawn: {} })), + onChange: noop, onOpenNode: noop, onCreateNode: noop, onRenameNode: noop, onEditSticky: noop, + onDeleteNode: noop, onModalNodeChange: noop, onBrowserNav: noop, onSetIcon: noop + }) + } + act(() => root.render(createElement(MountedBoard))) + function card() { + const title = Array.from(host.querySelectorAll('.kanban-card__title')).find(el => el.textContent === node.title) + expect(title).toBeTruthy() + return title!.closest('.kanban-card--session')! + } + return { store, platform, project, liveNodes, card } +} + +for (const surface of ['desktop', 'server'] as const) for (const kind of ['local', 'ssh'] as const) describe(`phone Board updates on a dirty mounted ${surface} ${kind} project`, () => { + it('moves and labels the mounted card without a conflict, reload, or lost local drag', async () => { + const f = await fixture(surface, kind) + expect(f.card().closest('.kanban-col')?.querySelector('.kanban-col__title')?.textContent).toBe('Ungrouped') + await act(async () => { expect(await f.store.setRemoteCardColumn(f.project.id, node.id, 'kcol-progress')).toBe(true) }) + expect(f.card().closest('.kanban-col')?.querySelector('.kanban-col__title')?.textContent).toBe('In Progress') + await act(async () => { expect(await f.store.editRemoteCardLabels(f.project.id, node.id, { add: ['klbl-hike'] })).toMatchObject({ edited: true }) }) + expect(f.card().querySelector('.kanban-label-chip')?.textContent).toBe('Hike QA') + expect({ mounts, conflicts, reloads }).toEqual({ mounts: 1, conflicts: 0, reloads: 0 }) + expect(f.platform.sent.filter(s => s.channel === IPC.workspaceExternalChange)).toEqual([]) + // The renderer's next ordinary save uses the adopted Board and its still-live canvas nodes. + useProjects.getState().commitCanvas(f.project.id, f.liveNodes, f.project.viewport) + await f.store.save(useProjects.getState().toWorkspace()) + const persisted = (await f.store.load()).projects[0]! + expect(persisted.kanban?.assignments).toEqual([{ nodeId: node.id, columnId: 'kcol-progress' }]) + expect(persisted.kanban?.meta?.[0]?.labels).toEqual(['klbl-hike']) + expect(persisted.nodes[0]?.position).toEqual({ x: 900, y: 700 }) + }) + + it('adopts phone-seeded default columns into the already mounted Board', async () => { + const f = await fixture(surface, kind, null) + await act(async () => { expect(await f.store.ensureRemoteBoard(f.project.id)).toHaveLength(3) }) + const adopted = useProjects.getState().getProject(f.project.id)?.kanban + expect(adopted?.columns.map(c => c.title)).toEqual(['To Do', 'In Progress', 'Done']) + expect(host.querySelectorAll('.kanban-col')).toHaveLength(4) + expect({ mounts, conflicts, reloads }).toEqual({ mounts: 1, conflicts: 0, reloads: 0 }) + }) +}) diff --git a/test/server/headless-e2e.test.ts b/test/server/headless-e2e.test.ts index 2e9fba99bf..a1aa16e2ef 100644 --- a/test/server/headless-e2e.test.ts +++ b/test/server/headless-e2e.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import fs from 'fs' import os from 'os' import path from 'path' import net from 'net' import { startServer } from '../../src/server/index' +import { PtyManager } from '../../src/core/pty-manager' async function unusedLoopbackPort(): Promise { const probe = net.createServer() @@ -29,6 +30,8 @@ describe('server headless mode: boots core services, binds no public listener', it('startServer with headless:true returns port 0 and closes cleanly', async () => { const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-headless-')) const sentinelPort = await unusedLoopbackPort() + // Observe the actual backend decision: headless boot also serves managed New when available. + const supportsManaged = vi.spyOn(PtyManager.prototype, 'supportsManagedCreation') try { const srv = await startServer({ port: sentinelPort, @@ -43,6 +46,17 @@ describe('server headless mode: boots core services, binds no public listener', }) // Nothing bound: the sentinel port is 0. expect(srv.port).toBe(0) + // Actual boot serves Board and optionally managed New; renderer nudges remain absent. + const advertisementFile = path.join(dataDir, 'ssh-actions/advertisement.json') + const advertisement = JSON.parse(fs.readFileSync(advertisementFile, 'utf8')) + expect(advertisement.version).toBe(1) + expect(advertisement.remoteProjects).toBe(false) + expect(supportsManaged).toHaveBeenCalled() + expect(advertisement.methods).toEqual([ + 'projects.ensureBoard', 'projects.setCardColumn', 'projects.editCardLabels', + ...(supportsManaged.mock.results[0].value ? ['sessions.createManagedV1'] : []) + ]) + expect(fs.statSync(advertisementFile).mode & 0o777).toBe(0o600) // And the configured ephemeral port is NOT listening — a connect attempt is refused. const listening = await new Promise((resolve) => { const sock = net @@ -58,7 +72,9 @@ describe('server headless mode: boots core services, binds no public listener', }) expect(listening).toBe(false) await srv.close() + expect(fs.existsSync(advertisementFile)).toBe(false) } finally { + supportsManaged.mockRestore() fs.rmSync(dataDir, { recursive: true, force: true }) } }, 30_000) diff --git a/tsconfig.node.json b/tsconfig.node.json index 507fbda932..636462e452 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -22,6 +22,11 @@ "src/shared/**/*", "src/core/**/*", "src/server/**/*", - "src/session-host/**/*" + "src/session-host/**/*", + // The Android interop fixture runs the desktop's real host code and implements its interfaces + // (HostPtyManager, the kanban/inbox/nodeActions bridge). esbuild only strips its types, and the + // Android workflow runs no tsc, so `npm run typecheck` (every CI run) is what catches a desktop + // change that breaks it (audit A67). + "android/protocol/src/test/interop/**/*.ts" ] }